Skip to content

Commit

Permalink
taking main
Browse files Browse the repository at this point in the history
  • Loading branch information
thomhickey committed Nov 1, 2024
2 parents 8ec54a0 + 637cbc2 commit 3b95394
Show file tree
Hide file tree
Showing 15 changed files with 288 additions and 42 deletions.
8 changes: 6 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
EMAIL_SERVICE=smtp
EMAIL_AUTH_USER=
EMAIL_AUTH_PASS=
EMAIL_FROM=no-reply@compassiep.org
EMAIL_HOST=localhost
EMAIL_PORT=1025
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
2 changes: 1 addition & 1 deletion src/backend/db/zapatos/schema.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 17 additions & 8 deletions src/backend/lib/db_helpers/case_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<user.Selectable | undefined> {
): Promise<user.Selectable> {
const { first_name, last_name, email } = para;

let paraData = await db
Expand All @@ -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
);
}
Expand All @@ -63,15 +63,24 @@ export async function sendInviteEmail(
fromEmail: string,
toEmail: string,
first_name: string,
caseManagerName: string,
case_manager_name: string,
env: Env
): Promise<void> {
await getTransporter(env).sendMail({
from: fromEmail,
to: toEmail,
subject: "Para-professional email confirmation",
text: "Email confirmation",
html: `<p>Dear ${first_name},</p><p>Welcome to the data collection team for SFUSD.EDU!</p><p>I am writing to invite you to join our data collection efforts for our students. We are using an online platform called <strong>Project Compass</strong> to track and monitor student progress, and your participation is crucial to the success of this initiative.</p><p>To access Project Compass and begin collecting data, please follow these steps:</p><ul><li>Go to the website: (<a href="https://staging.compassiep.com/">https://staging.compassiep.com/</a>)</li> <li>Login using your provided username and password</li><li>Once logged in, navigate to the dashboard where you would see the student goals page</li></ul><p>By clicking on the <strong>data collection</strong> 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.</p><p>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.</p><p>Thank you,</p><p>${caseManagerName}<br>Case Manager</p>`,
subject: `Welcome to ${case_manager_name}'s classroom`,
text: `${first_name}, get set up for data collection with Compass`,
html: `Hi ${first_name}! <br/>
${case_manager_name} has added you as a staff member for their classroom in Compass. <br/>
Compass is an all in one tool for collecting data for students’ IEP goals and empowering your classroom team to better assist students. <br/> <br/>
How does Compass work? <br/>
Compass will help you organize data collection for the students you work with and securely store the data you collect. <br/>
${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. <br/><br/>
Getting started <br/>
To get set up with Compass, use the link below and log in with the email address you received this message at. <br/>
Thank you for the key role you play in improving student outcomes!`,
});
return;
}
Expand Down
19 changes: 13 additions & 6 deletions src/backend/lib/nodemailer.ts
Original file line number Diff line number Diff line change
@@ -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);
};
8 changes: 6 additions & 2 deletions src/backend/lib/types/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
6 changes: 3 additions & 3 deletions src/backend/routers/case_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand Down
137 changes: 135 additions & 2 deletions src/backend/routers/iep.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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",

Check failure on line 109 in src/backend/routers/iep.test.ts

View workflow job for this annotation

GitHub Actions / type-check

Type '"case_manager"' is not assignable to type 'UserType.Para | UserType.CaseManager | UserType.Admin | undefined'.
});

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",

Check failure on line 165 in src/backend/routers/iep.test.ts

View workflow job for this annotation

GitHub Actions / type-check

Type '"case_manager"' is not assignable to type 'UserType.Para | UserType.CaseManager | UserType.Admin | undefined'.
});

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,
Expand Down
26 changes: 26 additions & 0 deletions src/backend/routers/iep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/backend/routers/para.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
);
});

Expand Down
Loading

0 comments on commit 3b95394

Please sign in to comment.