diff --git a/.env.example b/.env.example index 1d033f81..296ae0f0 100644 --- a/.env.example +++ b/.env.example @@ -16,5 +16,9 @@ S3_USER_UPLOADS_ACCESS_KEY_ID=minioadmin S3_USER_UPLOADS_SECRET_ACCESS_KEY=minioadmin S3_USER_UPLOADS_BUCKET_NAME=compass-files -EMAIL=no.reply.project.compass@gmail.com -EMAIL_PASS= \ No newline at end of file +EMAIL_SERVICE=smtp +EMAIL_AUTH_USER= +EMAIL_AUTH_PASS= +EMAIL_FROM=no-reply@compassiep.org +EMAIL_HOST=localhost +EMAIL_PORT=1025 diff --git a/README.md b/README.md index 31d92fd3..f4abb265 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,35 @@ To update GOOGLE_CLIENT_SECRET: - Copy the google client secret after creation of the client id +### Email + +**Option 1: Use Mailcatcher in Docker** + +If you're following _Option 1_ above to run supporting services in Docker, the `.env.example` file is already configured to send email to the Mailcatcher server running on port 1025 for SMTP. The Mailcatcher server will "catch" all email sent and save it in memory to be viewed in its web-based interface running on port 1080. Open it in your web browser at: http://localhost:1080 + +**Option 2: Configure a live mail server** + +If you're not running supporting services in Docker, or wish to test email sending to some test accounts (please take care not to send test emails to real people!), you can configure the following variables in your `.env` file: + +1. For an SMTP server + ``` + EMAIL_SERVICE=smtp + EMAIL_AUTH_USER=[username for your SMTP server] + EMAIL_AUTH_PASS=[password for your SMTP server] + EMAIL_FROM=no-reply@compassiep.org + EMAIL_HOST=[host name for your SMTP server] + EMAIL_PORT=[port for your SMTP server- typically 587 or 465 for secure connections] + ``` +2. For a Gmail account + ``` + EMAIL_SERVICE=gmail + EMAIL_AUTH_USER=[your Gmail address] + EMAIL_AUTH_PASS=[your Gmail password] + EMAIL_FROM=[your Gmail address] + EMAIL_HOST= + EMAIL_PORT= + ``` + ### Running tests The database container does not need to be started to run tests, but Docker Desktop must be running in the background. diff --git a/src/backend/db/migrations/2_add-uniqueness-constraint-on-tasks.sql b/src/backend/db/migrations/2_add-uniqueness-constraint-on-tasks.sql new file mode 100644 index 00000000..5d5abe35 --- /dev/null +++ b/src/backend/db/migrations/2_add-uniqueness-constraint-on-tasks.sql @@ -0,0 +1,7 @@ +-- Each task should have a unique subgoal_id - assignee_id combination +-- which corresponds to a unique benchmark / para combo +ALTER TABLE task +ADD CONSTRAINT subgoal_assignee_unique UNIQUE (subgoal_id, assignee_id); + +-- Add index to allow easy queries of tasks by assignee +CREATE INDEX idx_task_assignee ON task(assignee_id); \ No newline at end of file diff --git a/src/backend/db/zapatos/schema.d.ts b/src/backend/db/zapatos/schema.d.ts index aa9f5c9f..8707e24b 100644 --- a/src/backend/db/zapatos/schema.d.ts +++ b/src/backend/db/zapatos/schema.d.ts @@ -2165,7 +2165,7 @@ declare module 'zapatos/schema' { */ seen?: boolean | db.Parameter | db.DefaultType | db.SQLFragment | db.SQLFragment | db.DefaultType | db.SQLFragment>; } - export type UniqueIndex = 'task_pkey'; + export type UniqueIndex = 'subgoal_assignee_unique' | 'task_pkey'; export type Column = keyof Selectable; export type OnlyCols = Pick; export type SQLExpression = Table | db.ColumnNames | db.ColumnValues | Whereable | Column | db.ParentColumn | db.GenericSQLExpression; diff --git a/src/backend/lib/db_helpers/case_manager.ts b/src/backend/lib/db_helpers/case_manager.ts index 176abee9..a614c1cc 100644 --- a/src/backend/lib/db_helpers/case_manager.ts +++ b/src/backend/lib/db_helpers/case_manager.ts @@ -18,11 +18,11 @@ interface paraInputProps { export async function createPara( para: paraInputProps, db: KyselyDatabaseInstance, - case_manager_id: string, + case_manager_name: string, from_email: string, to_email: string, env: Env -): Promise { +): Promise { const { first_name, last_name, email } = para; let paraData = await db @@ -41,14 +41,14 @@ export async function createPara( role: UserType.Para, }) .returningAll() - .executeTakeFirst(); + .executeTakeFirstOrThrow(); // promise, will not interfere with returning paraData void sendInviteEmail( from_email, to_email, first_name, - case_manager_id, + case_manager_name, env ); } @@ -63,15 +63,24 @@ export async function sendInviteEmail( fromEmail: string, toEmail: string, first_name: string, - caseManagerName: string, + case_manager_name: string, env: Env ): Promise { await getTransporter(env).sendMail({ from: fromEmail, to: toEmail, - subject: "Para-professional email confirmation", - text: "Email confirmation", - html: `

Dear ${first_name},

Welcome to the data collection team for SFUSD.EDU!

I am writing to invite you to join our data collection efforts for our students. We are using an online platform called Project Compass to track and monitor student progress, and your participation is crucial to the success of this initiative.

To access Project Compass and begin collecting data, please follow these steps:

  • Go to the website: (https://staging.compassiep.com/)
  • Login using your provided username and password
  • Once logged in, navigate to the dashboard where you would see the student goals page

By clicking on the data collection button, you will be directed to the instructions outlining the necessary steps for data collection. Simply follow the provided instructions and enter the required data points accurately.

If you encounter any difficulties or have any questions, please feel free to reach out to me. I am here to assist you throughout the process and ensure a smooth data collection experience. Your dedication and contribution will make a meaningful impact on our students' educational journeys.

Thank you,

${caseManagerName}
Case Manager

`, + subject: `Welcome to ${case_manager_name}'s classroom`, + text: `${first_name}, get set up for data collection with Compass`, + html: `Hi ${first_name}!
+${case_manager_name} has added you as a staff member for their classroom in Compass.
+Compass is an all in one tool for collecting data for students’ IEP goals and empowering your classroom team to better assist students.

+How does Compass work?
+Compass will help you organize data collection for the students you work with and securely store the data you collect.
+${case_manager_name} will add you to data collection tasks for specific student goals. Upon logging in, you’ll see which students you’re expected to collect data for. +Instructions from ${case_manager_name} will be available with each assignment. When you’re ready to begin, you’ll be able to collect and submit data and notes directly in the app.

+Getting started
+To get set up with Compass, use the link below and log in with the email address you received this message at.
+Thank you for the key role you play in improving student outcomes!`, }); return; } diff --git a/src/backend/lib/nodemailer.ts b/src/backend/lib/nodemailer.ts index 0062aa67..877f5723 100644 --- a/src/backend/lib/nodemailer.ts +++ b/src/backend/lib/nodemailer.ts @@ -1,11 +1,18 @@ import { createTransport } from "nodemailer"; +import SMTPTransport from "nodemailer/lib/smtp-transport"; import { Env } from "./types"; -export const getTransporter = (environment: Env) => - createTransport({ - service: "gmail", +export const getTransporter = (env: Env) => { + const options: SMTPTransport.Options = { + service: env.EMAIL_SERVICE, auth: { - user: environment.EMAIL, - pass: environment.EMAIL_PASS, + user: env.EMAIL_AUTH_USER, + pass: env.EMAIL_AUTH_PASS, }, - }); + }; + if (env.EMAIL_SERVICE === "smtp") { + options.host = env.EMAIL_HOST; + options.port = parseInt(env.EMAIL_PORT, 10); + } + return createTransport(options); +}; diff --git a/src/backend/lib/types/env.ts b/src/backend/lib/types/env.ts index 209280bd..07f0e859 100644 --- a/src/backend/lib/types/env.ts +++ b/src/backend/lib/types/env.ts @@ -5,6 +5,10 @@ export interface Env { S3_USER_UPLOADS_ACCESS_KEY_ID: string; S3_USER_UPLOADS_SECRET_ACCESS_KEY: string; S3_USER_UPLOADS_BUCKET_NAME: string; - EMAIL: string; - EMAIL_PASS: string; + EMAIL_SERVICE: string; + EMAIL_AUTH_USER: string; + EMAIL_AUTH_PASS: string; + EMAIL_FROM: string; + EMAIL_HOST: string; + EMAIL_PORT: string; } diff --git a/src/backend/routers/case_manager.ts b/src/backend/routers/case_manager.ts index 1ebb1b36..00fafda4 100644 --- a/src/backend/routers/case_manager.ts +++ b/src/backend/routers/case_manager.ts @@ -164,14 +164,14 @@ export const case_manager = router({ const para = await createPara( req.input, req.ctx.db, - req.ctx.auth.userId, - req.ctx.env.EMAIL, + req.ctx.auth.session.user?.name ?? "", + req.ctx.env.EMAIL_FROM, req.input.email, req.ctx.env ); return await assignParaToCaseManager( - para?.user_id || "", + para.user_id, req.ctx.auth.userId, req.ctx.db ); diff --git a/src/backend/routers/iep.test.ts b/src/backend/routers/iep.test.ts index 424edf16..ca306a3c 100644 --- a/src/backend/routers/iep.test.ts +++ b/src/backend/routers/iep.test.ts @@ -36,6 +36,21 @@ test("basic flow - add/get goals, subgoals, tasks", async (t) => { number_of_trials: 15, }); + const subgoal1 = await trpc.iep.addSubgoal.mutate({ + goal_id: goal1!.goal_id, + status: "Complete", + description: "subgoal 1", + setup: "", + instructions: "", + materials: "materials", + target_level: 100, + baseline_level: 20, + metric_name: "words", + attempts_per_trial: 10, + number_of_trials: 30, + }); + const subgoal1Id = subgoal1!.subgoal_id; + const subgoal2 = await trpc.iep.addSubgoal.mutate({ goal_id: goal1!.goal_id, status: "Complete", @@ -52,7 +67,7 @@ test("basic flow - add/get goals, subgoals, tasks", async (t) => { const subgoal2Id = subgoal2!.subgoal_id; await trpc.iep.addTask.mutate({ - subgoal_id: subgoal2Id, + subgoal_id: subgoal1Id, assignee_id: para_id, due_date: new Date("2023-12-31"), trial_count: 5, @@ -71,7 +86,7 @@ test("basic flow - add/get goals, subgoals, tasks", async (t) => { const gotSubgoals = await trpc.iep.getSubgoals.query({ goal_id: goal1!.goal_id, }); - t.is(gotSubgoals.length, 2); + t.is(gotSubgoals.length, 3); const gotSubgoal = await trpc.iep.getSubgoal.query({ subgoal_id: subgoal2Id, @@ -89,6 +104,124 @@ test("basic flow - add/get goals, subgoals, tasks", async (t) => { ); }); +test("addTask - no duplicate subgoal_id + assigned_id combo", async (t) => { + const { trpc, seed } = await getTestServer(t, { + authenticateAs: "case_manager", + }); + + const para_id = seed.para.user_id; + + const iep = await trpc.student.addIep.mutate({ + student_id: seed.student.student_id, + start_date: new Date("2023-01-01"), + end_date: new Date("2023-12-31"), + }); + + const goal1 = await trpc.iep.addGoal.mutate({ + iep_id: iep.iep_id, + description: "goal 1", + category: "writing", + }); + + const subgoal1 = await trpc.iep.addSubgoal.mutate({ + goal_id: goal1!.goal_id, + status: "Complete", + description: "subgoal 1", + setup: "", + instructions: "", + materials: "materials", + target_level: 100, + baseline_level: 20, + metric_name: "words", + attempts_per_trial: 10, + number_of_trials: 30, + }); + const subgoal1Id = subgoal1!.subgoal_id; + + await trpc.iep.addTask.mutate({ + subgoal_id: subgoal1Id, + assignee_id: para_id, + due_date: new Date("2023-12-31"), + trial_count: 5, + }); + + const error = await t.throwsAsync(async () => { + await trpc.iep.addTask.mutate({ + subgoal_id: subgoal1Id, + assignee_id: para_id, + due_date: new Date("2024-03-31"), + trial_count: 1, + }); + }); + + t.is( + error?.message, + "Task already exists: This subgoal has already been assigned to the same para" + ); +}); + +test("assignTaskToParas - no duplicate subgoal_id + para_id combo", async (t) => { + const { trpc, seed } = await getTestServer(t, { + authenticateAs: "case_manager", + }); + + const para_1 = seed.para; + + const para_2 = await trpc.para.createPara.mutate({ + first_name: "Foo", + last_name: "Bar", + email: "foo.bar@email.com", + }); + + const iep = await trpc.student.addIep.mutate({ + student_id: seed.student.student_id, + start_date: new Date("2023-01-01"), + end_date: new Date("2023-12-31"), + }); + + const goal1 = await trpc.iep.addGoal.mutate({ + iep_id: iep.iep_id, + description: "goal 1", + category: "writing", + }); + + const subgoal1 = await trpc.iep.addSubgoal.mutate({ + goal_id: goal1!.goal_id, + status: "Complete", + description: "subgoal 1", + setup: "", + instructions: "", + materials: "materials", + target_level: 100, + baseline_level: 20, + metric_name: "words", + attempts_per_trial: 10, + number_of_trials: 30, + }); + const subgoal1Id = subgoal1!.subgoal_id; + + await trpc.iep.assignTaskToParas.mutate({ + subgoal_id: subgoal1Id, + para_ids: [para_1.user_id], + due_date: new Date("2023-12-31"), + trial_count: 5, + }); + + const error = await t.throwsAsync(async () => { + await trpc.iep.assignTaskToParas.mutate({ + subgoal_id: subgoal1Id, + para_ids: [para_1.user_id, para_2.user_id], + due_date: new Date("2024-03-31"), + trial_count: 1, + }); + }); + + t.is( + error?.message, + "Task already exists: This subgoal has already been assigned to one or more of these paras" + ); +}); + test("add benchmark - check full schema", async (t) => { const { trpc, seed } = await getTestServer(t, { authenticateAs: UserType.CaseManager, diff --git a/src/backend/routers/iep.ts b/src/backend/routers/iep.ts index 62e620ea..30d9d5e3 100644 --- a/src/backend/routers/iep.ts +++ b/src/backend/routers/iep.ts @@ -135,6 +135,19 @@ export const iep = router({ .mutation(async (req) => { const { subgoal_id, assignee_id, due_date, trial_count } = req.input; + const existingTask = await req.ctx.db + .selectFrom("task") + .where("subgoal_id", "=", subgoal_id) + .where("assignee_id", "=", assignee_id) + .selectAll() + .executeTakeFirst(); + + if (existingTask) { + throw new Error( + "Task already exists: This subgoal has already been assigned to the same para" + ); + } + const result = await req.ctx.db .insertInto("task") .values({ @@ -160,6 +173,19 @@ export const iep = router({ .mutation(async (req) => { const { subgoal_id, para_ids, due_date, trial_count } = req.input; + const existingTasks = await req.ctx.db + .selectFrom("task") + .where("subgoal_id", "=", subgoal_id) + .where("assignee_id", "in", para_ids) + .selectAll() + .execute(); + + if (existingTasks.length > 0) { + throw new Error( + "Task already exists: This subgoal has already been assigned to one or more of these paras" + ); + } + const result = await req.ctx.db .insertInto("task") .values( diff --git a/src/backend/routers/para.test.ts b/src/backend/routers/para.test.ts index ac7101b7..2ba0aa51 100644 --- a/src/backend/routers/para.test.ts +++ b/src/backend/routers/para.test.ts @@ -96,7 +96,7 @@ test("createPara", async (t) => { t.true( nodemailerMock.mock .getSentMail() - .some((mail) => mail.subject?.includes("confirmation")) + .some((mail) => mail.subject?.includes("classroom")) ); }); diff --git a/src/backend/routers/para.ts b/src/backend/routers/para.ts index 7c070eb0..2800b402 100644 --- a/src/backend/routers/para.ts +++ b/src/backend/routers/para.ts @@ -49,11 +49,10 @@ export const para = router({ req.input, req.ctx.db, req.ctx.auth.session.user?.name ?? "", - req.ctx.env.EMAIL, + req.ctx.env.EMAIL_FROM, email, req.ctx.env ); - return para; // TODO: Logic for sending email to staff. Should email be sent everytime or only first time? Should staff be notified that they are added to a certain case manager's list? diff --git a/src/backend/tests/fixtures/get-test-server.ts b/src/backend/tests/fixtures/get-test-server.ts index 51a4f0d2..677d7bdf 100644 --- a/src/backend/tests/fixtures/get-test-server.ts +++ b/src/backend/tests/fixtures/get-test-server.ts @@ -39,8 +39,12 @@ export const getTestServer = async ( S3_USER_UPLOADS_ACCESS_KEY_ID: minio.accessKey, S3_USER_UPLOADS_SECRET_ACCESS_KEY: minio.secretKey, S3_USER_UPLOADS_BUCKET_NAME: minio.bucket, - EMAIL: "example string", - EMAIL_PASS: "example string", + EMAIL_SERVICE: "smtp", + EMAIL_FROM: "no-reply@compassiep.org", + EMAIL_AUTH_USER: "", // note that these server settings will not be used, nodemailer is mocked + EMAIL_AUTH_PASS: "", + EMAIL_HOST: "localhost", + EMAIL_PORT: "1025", }; // Use statically-built Next.js fixture (if multiple instances of the built-in next() dev server are running, they try to concurrently mutate the same files). diff --git a/src/components/benchmarks/BenchmarkAssignmentModal.tsx b/src/components/benchmarks/BenchmarkAssignmentModal.tsx index 701d2ff8..654463fc 100644 --- a/src/components/benchmarks/BenchmarkAssignmentModal.tsx +++ b/src/components/benchmarks/BenchmarkAssignmentModal.tsx @@ -54,9 +54,12 @@ export const BenchmarkAssignmentModal = ( subgoal_id: props.benchmark_id, }); + const [errorMessage, setErrorMessage] = useState(""); + const assignTaskToPara = trpc.iep.assignTaskToParas.useMutation(); const handleParaToggle = (paraId: string) => () => { + setErrorMessage(""); setSelectedParaIds((prev) => { if (prev.includes(paraId)) { return prev.filter((id) => id !== paraId); @@ -69,6 +72,7 @@ export const BenchmarkAssignmentModal = ( const handleClose = () => { props.onClose(); setSelectedParaIds([]); + setErrorMessage(""); setCurrentModalSelection("PARA_SELECTION"); }; @@ -90,19 +94,27 @@ export const BenchmarkAssignmentModal = ( setCurrentModalSelection(nextStep); } else { // Reached end, save - await assignTaskToPara.mutateAsync({ - subgoal_id: props.benchmark_id, - para_ids: selectedParaIds, - due_date: - assignmentDuration.type === "until_date" - ? assignmentDuration.date - : undefined, - trial_count: - assignmentDuration.type === "minimum_number_of_collections" - ? assignmentDuration.minimumNumberOfCollections - : undefined, - }); - handleClose(); + try { + await assignTaskToPara.mutateAsync({ + subgoal_id: props.benchmark_id, + para_ids: selectedParaIds, + due_date: + assignmentDuration.type === "until_date" + ? assignmentDuration.date + : undefined, + trial_count: + assignmentDuration.type === "minimum_number_of_collections" + ? assignmentDuration.minimumNumberOfCollections + : undefined, + }); + handleClose(); + } catch (err) { + // TODO: issue #450 + console.log(err); + if (err instanceof Error) { + setErrorMessage(err.message); + } + } } }; @@ -173,6 +185,12 @@ export const BenchmarkAssignmentModal = ( /> )} + + {errorMessage && ( + + {errorMessage} + + )} {currentModalSelection !== STEPS[0] && (