Skip to content

Commit aea652d

Browse files
authored
Feat: add uniqueness constraint to task table (#454)
* Feat: add uniqueness constraint to task table Add uniqueness constraint on tasks to prevent assigning the same benchmark (subgoal) to the same para (assignee). * Katrina feedback: more informative error message * Remove comment, code is self-explanatory * Add custom error to assignTaskToParas as well, plus spec * Tweak error message * Add error message to frontend modal * cause createPara to return para or throw rather than undefined * handle type of error in frontend
1 parent 3fe2344 commit aea652d

File tree

7 files changed

+203
-19
lines changed

7 files changed

+203
-19
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- Each task should have a unique subgoal_id - assignee_id combination
2+
-- which corresponds to a unique benchmark / para combo
3+
ALTER TABLE task
4+
ADD CONSTRAINT subgoal_assignee_unique UNIQUE (subgoal_id, assignee_id);
5+
6+
-- Add index to allow easy queries of tasks by assignee
7+
CREATE INDEX idx_task_assignee ON task(assignee_id);

src/backend/db/zapatos/schema.d.ts

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/backend/lib/db_helpers/case_manager.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export async function createPara(
2121
from_email: string,
2222
to_email: string,
2323
env: Env
24-
): Promise<user.Selectable | undefined> {
24+
): Promise<user.Selectable> {
2525
const { first_name, last_name, email } = para;
2626

2727
let paraData = await db
@@ -40,7 +40,7 @@ export async function createPara(
4040
role: "staff",
4141
})
4242
.returningAll()
43-
.executeTakeFirst();
43+
.executeTakeFirstOrThrow();
4444

4545
// promise, will not interfere with returning paraData
4646
void sendInviteEmail(

src/backend/routers/case_manager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ export const case_manager = router({
171171
);
172172

173173
return await assignParaToCaseManager(
174-
para?.user_id || "",
174+
para.user_id,
175175
req.ctx.auth.userId,
176176
req.ctx.db
177177
);

src/backend/routers/iep.test.ts

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,21 @@ test("basic flow - add/get goals, subgoals, tasks", async (t) => {
3535
number_of_trials: 15,
3636
});
3737

38+
const subgoal1 = await trpc.iep.addSubgoal.mutate({
39+
goal_id: goal1!.goal_id,
40+
status: "Complete",
41+
description: "subgoal 1",
42+
setup: "",
43+
instructions: "",
44+
materials: "materials",
45+
target_level: 100,
46+
baseline_level: 20,
47+
metric_name: "words",
48+
attempts_per_trial: 10,
49+
number_of_trials: 30,
50+
});
51+
const subgoal1Id = subgoal1!.subgoal_id;
52+
3853
const subgoal2 = await trpc.iep.addSubgoal.mutate({
3954
goal_id: goal1!.goal_id,
4055
status: "Complete",
@@ -51,7 +66,7 @@ test("basic flow - add/get goals, subgoals, tasks", async (t) => {
5166
const subgoal2Id = subgoal2!.subgoal_id;
5267

5368
await trpc.iep.addTask.mutate({
54-
subgoal_id: subgoal2Id,
69+
subgoal_id: subgoal1Id,
5570
assignee_id: para_id,
5671
due_date: new Date("2023-12-31"),
5772
trial_count: 5,
@@ -70,7 +85,7 @@ test("basic flow - add/get goals, subgoals, tasks", async (t) => {
7085
const gotSubgoals = await trpc.iep.getSubgoals.query({
7186
goal_id: goal1!.goal_id,
7287
});
73-
t.is(gotSubgoals.length, 2);
88+
t.is(gotSubgoals.length, 3);
7489

7590
const gotSubgoal = await trpc.iep.getSubgoal.query({
7691
subgoal_id: subgoal2Id,
@@ -88,6 +103,124 @@ test("basic flow - add/get goals, subgoals, tasks", async (t) => {
88103
);
89104
});
90105

106+
test("addTask - no duplicate subgoal_id + assigned_id combo", async (t) => {
107+
const { trpc, seed } = await getTestServer(t, {
108+
authenticateAs: "case_manager",
109+
});
110+
111+
const para_id = seed.para.user_id;
112+
113+
const iep = await trpc.student.addIep.mutate({
114+
student_id: seed.student.student_id,
115+
start_date: new Date("2023-01-01"),
116+
end_date: new Date("2023-12-31"),
117+
});
118+
119+
const goal1 = await trpc.iep.addGoal.mutate({
120+
iep_id: iep.iep_id,
121+
description: "goal 1",
122+
category: "writing",
123+
});
124+
125+
const subgoal1 = await trpc.iep.addSubgoal.mutate({
126+
goal_id: goal1!.goal_id,
127+
status: "Complete",
128+
description: "subgoal 1",
129+
setup: "",
130+
instructions: "",
131+
materials: "materials",
132+
target_level: 100,
133+
baseline_level: 20,
134+
metric_name: "words",
135+
attempts_per_trial: 10,
136+
number_of_trials: 30,
137+
});
138+
const subgoal1Id = subgoal1!.subgoal_id;
139+
140+
await trpc.iep.addTask.mutate({
141+
subgoal_id: subgoal1Id,
142+
assignee_id: para_id,
143+
due_date: new Date("2023-12-31"),
144+
trial_count: 5,
145+
});
146+
147+
const error = await t.throwsAsync(async () => {
148+
await trpc.iep.addTask.mutate({
149+
subgoal_id: subgoal1Id,
150+
assignee_id: para_id,
151+
due_date: new Date("2024-03-31"),
152+
trial_count: 1,
153+
});
154+
});
155+
156+
t.is(
157+
error?.message,
158+
"Task already exists: This subgoal has already been assigned to the same para"
159+
);
160+
});
161+
162+
test("assignTaskToParas - no duplicate subgoal_id + para_id combo", async (t) => {
163+
const { trpc, seed } = await getTestServer(t, {
164+
authenticateAs: "case_manager",
165+
});
166+
167+
const para_1 = seed.para;
168+
169+
const para_2 = await trpc.para.createPara.mutate({
170+
first_name: "Foo",
171+
last_name: "Bar",
172+
email: "foo.bar@email.com",
173+
});
174+
175+
const iep = await trpc.student.addIep.mutate({
176+
student_id: seed.student.student_id,
177+
start_date: new Date("2023-01-01"),
178+
end_date: new Date("2023-12-31"),
179+
});
180+
181+
const goal1 = await trpc.iep.addGoal.mutate({
182+
iep_id: iep.iep_id,
183+
description: "goal 1",
184+
category: "writing",
185+
});
186+
187+
const subgoal1 = await trpc.iep.addSubgoal.mutate({
188+
goal_id: goal1!.goal_id,
189+
status: "Complete",
190+
description: "subgoal 1",
191+
setup: "",
192+
instructions: "",
193+
materials: "materials",
194+
target_level: 100,
195+
baseline_level: 20,
196+
metric_name: "words",
197+
attempts_per_trial: 10,
198+
number_of_trials: 30,
199+
});
200+
const subgoal1Id = subgoal1!.subgoal_id;
201+
202+
await trpc.iep.assignTaskToParas.mutate({
203+
subgoal_id: subgoal1Id,
204+
para_ids: [para_1.user_id],
205+
due_date: new Date("2023-12-31"),
206+
trial_count: 5,
207+
});
208+
209+
const error = await t.throwsAsync(async () => {
210+
await trpc.iep.assignTaskToParas.mutate({
211+
subgoal_id: subgoal1Id,
212+
para_ids: [para_1.user_id, para_2.user_id],
213+
due_date: new Date("2024-03-31"),
214+
trial_count: 1,
215+
});
216+
});
217+
218+
t.is(
219+
error?.message,
220+
"Task already exists: This subgoal has already been assigned to one or more of these paras"
221+
);
222+
});
223+
91224
test("add benchmark - check full schema", async (t) => {
92225
const { trpc, seed } = await getTestServer(t, {
93226
authenticateAs: "case_manager",

src/backend/routers/iep.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,19 @@ export const iep = router({
135135
.mutation(async (req) => {
136136
const { subgoal_id, assignee_id, due_date, trial_count } = req.input;
137137

138+
const existingTask = await req.ctx.db
139+
.selectFrom("task")
140+
.where("subgoal_id", "=", subgoal_id)
141+
.where("assignee_id", "=", assignee_id)
142+
.selectAll()
143+
.executeTakeFirst();
144+
145+
if (existingTask) {
146+
throw new Error(
147+
"Task already exists: This subgoal has already been assigned to the same para"
148+
);
149+
}
150+
138151
const result = await req.ctx.db
139152
.insertInto("task")
140153
.values({
@@ -160,6 +173,19 @@ export const iep = router({
160173
.mutation(async (req) => {
161174
const { subgoal_id, para_ids, due_date, trial_count } = req.input;
162175

176+
const existingTasks = await req.ctx.db
177+
.selectFrom("task")
178+
.where("subgoal_id", "=", subgoal_id)
179+
.where("assignee_id", "in", para_ids)
180+
.selectAll()
181+
.execute();
182+
183+
if (existingTasks.length > 0) {
184+
throw new Error(
185+
"Task already exists: This subgoal has already been assigned to one or more of these paras"
186+
);
187+
}
188+
163189
const result = await req.ctx.db
164190
.insertInto("task")
165191
.values(

src/components/benchmarks/BenchmarkAssignmentModal.tsx

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,12 @@ export const BenchmarkAssignmentModal = (
5454
subgoal_id: props.benchmark_id,
5555
});
5656

57+
const [errorMessage, setErrorMessage] = useState<string>("");
58+
5759
const assignTaskToPara = trpc.iep.assignTaskToParas.useMutation();
5860

5961
const handleParaToggle = (paraId: string) => () => {
62+
setErrorMessage("");
6063
setSelectedParaIds((prev) => {
6164
if (prev.includes(paraId)) {
6265
return prev.filter((id) => id !== paraId);
@@ -69,6 +72,7 @@ export const BenchmarkAssignmentModal = (
6972
const handleClose = () => {
7073
props.onClose();
7174
setSelectedParaIds([]);
75+
setErrorMessage("");
7276
setCurrentModalSelection("PARA_SELECTION");
7377
};
7478

@@ -90,19 +94,27 @@ export const BenchmarkAssignmentModal = (
9094
setCurrentModalSelection(nextStep);
9195
} else {
9296
// Reached end, save
93-
await assignTaskToPara.mutateAsync({
94-
subgoal_id: props.benchmark_id,
95-
para_ids: selectedParaIds,
96-
due_date:
97-
assignmentDuration.type === "until_date"
98-
? assignmentDuration.date
99-
: undefined,
100-
trial_count:
101-
assignmentDuration.type === "minimum_number_of_collections"
102-
? assignmentDuration.minimumNumberOfCollections
103-
: undefined,
104-
});
105-
handleClose();
97+
try {
98+
await assignTaskToPara.mutateAsync({
99+
subgoal_id: props.benchmark_id,
100+
para_ids: selectedParaIds,
101+
due_date:
102+
assignmentDuration.type === "until_date"
103+
? assignmentDuration.date
104+
: undefined,
105+
trial_count:
106+
assignmentDuration.type === "minimum_number_of_collections"
107+
? assignmentDuration.minimumNumberOfCollections
108+
: undefined,
109+
});
110+
handleClose();
111+
} catch (err) {
112+
// TODO: issue #450
113+
console.log(err);
114+
if (err instanceof Error) {
115+
setErrorMessage(err.message);
116+
}
117+
}
106118
}
107119
};
108120

@@ -173,6 +185,12 @@ export const BenchmarkAssignmentModal = (
173185
/>
174186
</Box>
175187
)}
188+
189+
{errorMessage && (
190+
<Box className={$benchmark.benchmarkDescriptionBox}>
191+
{errorMessage}
192+
</Box>
193+
)}
176194
<DialogActions>
177195
{currentModalSelection !== STEPS[0] && (
178196
<Button

0 commit comments

Comments
 (0)