Skip to content

MSPCA-21 Bulk Notifications - #24

Open
Yurika-Kan wants to merge 14 commits into
mainfrom
yk/mspca-21-bulk-notifications
Open

Yurika-Kan wants to merge 14 commits into
mainfrom
yk/mspca-21-bulk-notifications

Conversation

@Yurika-Kan

@Yurika-Kan Yurika-Kan commented Oct 27, 2026 •

Copy link
Copy Markdown

ℹ️ Issue

Closes MSPCA-21

📝 Description

Foster Coordinators can notify all or a filtered group of volunteers via email sent immediately or at a scheduled time.

immediate:  POST /notifications --> sendNow() --> SES
scheduled:  POST /notifications --> Scheduler (waits until sendAt) --> Lambda --> POST /notifications/send-scheduled --> sendNow() --> SES
  1. POST /notifications (new notifications/ controller, service, module, DTO)
    • body: subject, body, optional filters, optional sendAt
    • goes to active volunteers only, matching any filter. No filters = all active
    • BCC'd in batches of 49 - skips duplicates/malformed emails
    • 202 sent/scheduled · 400 bad body · 500 SES/Scheduler not configured
  2. Scheduled sends: sendAt creates a one time AWS schedule.
    • AWS remembers that time & when it fires, a Lambda calls POST /notifications/send-scheduled, and the schedule deletes itself
    • volunteer recipients are looked up at send time so we filter for all current active volunteers.
  3. POST /notifications/send-scheduled: Lambda only
    • Lambda has no Cognito token, so this route checks a shared secret header (NotificationsDispatchGuard)
    • 204 sent · 400 bad body · 401 bad/missing secret
  4. AWS infra + setup guide: dispatch Lambda, IAM roles, @aws-sdk/client-scheduler, new env vars in example.env, and setup steps in infra/notification-dispatch-lambda/README.md

✔️ Verification

  • unit: npx jest apps/backend/src/notifications: 43/43 pass (email + Scheduler mocked)
  • live: dev AWS and SES, backend run locally behind my exposed local ngrok endpoint~ steps are in the README.
Test Result
Send now, filters: ["Dog"] 202, only the Dog volunteer got it image
Scheduled send, full chain all 4 Active volunteers got it about 2 min later image
Lambda --> backend 200, only the Cat volunteer got it
Lambda retries, backend down 3 attempts (1 + 2 retries), no email
Wrong/missing secret 401
Bad body (past sendAt, bad filter, missing field, line break in subject) 400
  • AWS resources, IAM roles + permissions created via CLI (see README), verified in AWS console
  • tested with 4 of my email addresses

Notes

scheduled sends need 3 values in your .env: AWS_NOTIFICATION_DISPATCH_LAMBDA_ARN, AWS_SCHEDULER_ROLE_ARN and NOTIFICATIONS_DISPATCH_SECRET. shared via slackington

emails go to spam: Gmail sender via SES. prod should send from a verified domain (e.g. c4cneu.com)

🏕️ Future Work

  • lambda endpoint auth/token - before prod: add @Public() to /send-scheduled Cognito guard is global, so the Lambda gets a 401 once auth is on. It works locally only because AUTH_DISABLED=true.
  • Role gating: coordinators only. Cognito groups or DB role?
  • rollback/failed bulk send - failed SES batches are only logged, with no retry or rollback, and a retry can double-send.

returns active volunteers matching any of the given foster types, or all
active volunteers when the list is empty. pending and inactive are excluded.
- post /notifications emails active volunteers, filtered by foster type,
  bcc'd in batches of 49
- with sendAt, creates a one-time eventbridge scheduler schedule instead
- post /notifications/send-scheduled is called by the dispatch lambda and
  guarded by a shared secret header
- service, controller, guard and module tests
lambda forwards a fired schedule to /notifications/send-scheduled. readme
covers the aws resources, env vars, local testing via tunnel, dev test
results and the prod checklist.
Copilot AI balanced review requested due to automatic review settings October 27, 2026 07:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

Yurika-Kan and others added 7 commits October 27, 2026 03:47
sendNow already drops malformed addresses and dedupes case-insensitively.
coordinators module now imports cognito, so the module spec pulls in jwks-rsa
and its esm jose dependency, which jest cannot parse.
teammates only need the env vars to connect. creation steps move below local
testing as create from scratch, for prod or a rebuild. notes that the single
lambda url is shared.
merge test results into the test table, collapse setup into one script block,
trim checklist, troubleshooting and refs.

@Juwang110 Juwang110 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

first pass comments, looks good overall tho!

@dburkhart07 dburkhart07 left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okay, wow! this is so well written im amazed yurika! such good documentation, such thorough research done, absolutely wonderful. i havent looked at the tests much since they will likely need updates pending my small changes. id recommend you move everything in the notifs folder aside from the dto, controller, and service (and their respective tests), and put those into a separate aws folder that i recommended. if need be, we can create a new notifications module too if thats necessary, but it shouldnt need much in it.

let me know if you have any confusion, i will thoroughly test and look at each of the tests after the comments are resolved.

also, i have asked max about cleaning up some of our roles within IAM, so be prepared to potentially adjust those within AWS. i wonder now if using terraform may actually be beneficial for us due to how much infrastructure we have setup

Comment thread example.env Resolved
@@ -0,0 +1,38 @@
/**

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a reason why we put this here instead of in the backend? maybe in an aws/client-scheduler/lambda-functions directory?

there would be a better spot for this if we were using terraform, but we arent, so i think the above suggestion may be better, lmk your thoughts

// retries (up to 2 by default, set in the function's async invocation config).
if (!response.ok) {
throw new Error(
`Dispatch failed: ${response.status} ${await response.text()}`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there are way we could potentially include how many retries are left on this?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lowkey no, lambda doesn't expose attempt # for async retries. workaround is to add a id to the error so we can count the lines with the same id

return this.repo.find({
where: {
status: VolunteerStatus.ACTIVE,
...(fosterTypes.length > 0 && { fosterType: In(fosterTypes) }),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not Resolve until complete:

note that foster types for each volunteer is going to be made into a list pending the merge of #11, so this will need to change later

Comment thread example.env Outdated
# AWS_NOTIFICATION_DISPATCH_LAMBDA_ARN=
# The role Scheduler assumes to invoke that Lambda (needs lambda:InvokeFunction):
# AWS_SCHEDULER_ROLE_ARN=
# Shared secret the Lambda presents to POST /notifications/send-scheduled. Must match the

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we not need to provide the full url that this is to be sent at? for right now, id imagine its localhost:3000/api/notifications/send-scheduled, correct? if so, can we update this?

);
}

await this.scheduler.send(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this be in a try catch block with some error handling?

const targetArn = process.env.AWS_NOTIFICATION_DISPATCH_LAMBDA_ARN;
const roleArn = process.env.AWS_SCHEDULER_ROLE_ARN;

if (!targetArn || !roleArn) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this shouldnt be needed pending module init changes

@@ -0,0 +1,130 @@
# Scheduled notifications: AWS setup

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this README can also go into the new aws folder

--policy-document "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":\"scheduler:CreateSchedule\",\"Resource\":\"arn:aws:scheduler:${AWS_REGION}:${ACCOUNT_ID}:schedule/default/notification-*\"},{\"Effect\":\"Allow\",\"Action\":\"iam:PassRole\",\"Resource\":\"arn:aws:iam::${ACCOUNT_ID}:role/mspca-notif-scheduler-role\",\"Condition\":{\"StringEquals\":{\"iam:PassedToService\":\"scheduler.amazonaws.com\"}}}]}"

# 5. backend env: paste into .env or the host's config, then restart
echo "AWS_NOTIFICATION_DISPATCH_LAMBDA_ARN=arn:aws:lambda:${AWS_REGION}:${ACCOUNT_ID}:function:mspca-notif-dispatch"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add step by step instructions for where to get each of these env variables here?

body!: string;

// Omitted/empty = every active Volunteer regardless of foster type.
@IsOptional()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add the validateIf that replaced all of our other IsOptional ones so that we cant put in null here? same with sendAt

match foster types with ArrayOverlap now that fosterType is a list (#11)
… require env vars at boot

- move infra/notification-dispatch-lambda to apps/backend/src/aws/scheduler/lambda/notif
- fail boot when any notifications env var is missing instead of logging
- test findActiveByFosterTypes filtering against array foster types
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants