From dd31c65acb440cd9dfc7181368a0d350fa0b88a5 Mon Sep 17 00:00:00 2001 From: yurikakan Date: Tue, 27 Oct 2026 03:32:39 -0400 Subject: [PATCH 01/11] feat(volunteers): add findActiveByFosterTypes query returns active volunteers matching any of the given foster types, or all active volunteers when the list is empty. pending and inactive are excluded. --- .../src/volunteers/volunteers.service.ts | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/volunteers/volunteers.service.ts b/apps/backend/src/volunteers/volunteers.service.ts index c283cde7a..7b8c3f1f0 100644 --- a/apps/backend/src/volunteers/volunteers.service.ts +++ b/apps/backend/src/volunteers/volunteers.service.ts @@ -7,9 +7,9 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, Repository } from 'typeorm'; import { FosterVolunteer } from './volunteers.entity'; -import { VolunteerStatus } from './volunteers.types'; +import { FosterType, VolunteerStatus } from './volunteers.types'; import { CreateVolunteerDto } from './dtos/create-volunteer.dto'; import { ConfirmVolunteerDto } from './dtos/confirm-volunteer.dto'; import { UpdateVolunteerDto } from './dtos/update-volunteer.dto'; @@ -350,4 +350,26 @@ export class VolunteersService { const saved = await this.repo.save(volunteer); return saved.animalPreferences; } + + /** + * Gets every active Volunteer whose foster type is one of `fosterTypes`. + * + * An empty `fosterTypes` matches every active Volunteer. Pending and inactive + * Volunteers are never returned. + * + * @param fosterTypes - The foster types to match, or an empty array for all. + * @returns The matching active Volunteers, or an empty array if there are none. + */ + async findActiveByFosterTypes( + fosterTypes: FosterType[], + ): Promise { + // shortcut: fosterType is a single enum today, so In() is an exact match. + // Once PR #11 makes the column an array this needs the && overlap operator. + return this.repo.find({ + where: { + status: VolunteerStatus.ACTIVE, + ...(fosterTypes.length > 0 && { fosterType: In(fosterTypes) }), + }, + }); + } } From 5ef68ce16aaf8427122ec2a67b9e78d29ff460dc Mon Sep 17 00:00:00 2001 From: yurikakan Date: Tue, 27 Oct 2026 03:32:43 -0400 Subject: [PATCH 02/11] chore(deps): add @aws-sdk/client-scheduler --- package.json | 1 + yarn.lock | 239 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 240 insertions(+) diff --git a/package.json b/package.json index 74e48863b..b3688aede 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@aws-amplify/ui-react": "^6.15.4", "@aws-sdk/client-cognito-identity-provider": "^3.1058.0", "@aws-sdk/client-s3": "^3.1073.0", + "@aws-sdk/client-scheduler": "^3.1156.0", "@aws-sdk/client-sesv2": "^3.1063.0", "@chakra-ui/react": "^3.37.0", "@emotion/react": "^11.14.0", diff --git a/yarn.lock b/yarn.lock index 9c67a1e6c..3c3dc1b89 100644 --- a/yarn.lock +++ b/yarn.lock @@ -498,6 +498,20 @@ "@smithy/types" "^4.14.3" tslib "^2.6.2" +"@aws-sdk/client-scheduler@^3.1156.0": + version "3.1156.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-scheduler/-/client-scheduler-3.1156.0.tgz#57b6bfdeb69069260f229c51b0311a386fb3c89c" + integrity sha512-ODbqitEsbf8gVWWdwu1UuiVDlQEAZLb1/NrdeaM1Qxmi1fQRzxD/B4f8gkcdKVW2dLDrNY0rmwFkOdAea+KUaw== + dependencies: + "@aws-sdk/core" "^3.978.5" + "@aws-sdk/credential-provider-node" "^3.972.88" + "@aws-sdk/types" "^3.975.0" + "@smithy/core" "^3.36.1" + "@smithy/fetch-http-handler" "^5.8.0" + "@smithy/node-http-handler" "^4.12.1" + "@smithy/types" "^4.19.0" + tslib "^2.6.2" + "@aws-sdk/client-sesv2@^3.1063.0": version "3.1063.0" resolved "https://registry.yarnpkg.com/@aws-sdk/client-sesv2/-/client-sesv2-3.1063.0.tgz#9d7a645d9fba560b6223c8a23637c4db0b37ec23" @@ -543,6 +557,20 @@ bowser "^2.11.0" tslib "^2.6.2" +"@aws-sdk/core@^3.978.5": + version "3.978.5" + resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.978.5.tgz#1c9442d0cf4d7acb1c51a4a35a804033f995812a" + integrity sha512-ruI0AWqcrfL20XDS6SwZIJFPOBZ6N0ADa9nLzWlBhxk1aoUjbUX3JkTR9vEmk7o35XpAHi68ahZ4T6RE2U9XyA== + dependencies: + "@aws-sdk/types" "^3.975.0" + "@aws-sdk/xml-builder" "^3.972.41" + "@aws/lambda-invoke-store" "^0.3.0" + "@smithy/core" "^3.36.1" + "@smithy/signature-v4" "^5.7.4" + "@smithy/types" "^4.19.0" + bowser "^2.11.0" + tslib "^2.6.2" + "@aws-sdk/credential-provider-env@^3.972.48": version "3.972.48" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.48.tgz#dbbb28a6bd125674c249394574b718668172b861" @@ -565,6 +593,17 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-env@^3.972.76": + version "3.972.76" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.76.tgz#4433bf572620128375d98f1ceca8912003124734" + integrity sha512-YxmL1Ar7bV9ZkJvtv+lj+MF6le76NnZnbmgySyZuxpZaCv0/1lZ3KxKy6RIj1h9ui2hWnVjZxbmTMeO0y/NrQA== + dependencies: + "@aws-sdk/core" "^3.978.5" + "@aws-sdk/types" "^3.975.0" + "@smithy/core" "^3.36.1" + "@smithy/types" "^4.19.0" + tslib "^2.6.2" + "@aws-sdk/credential-provider-http@^3.972.50": version "3.972.50" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.50.tgz#2e3360819a9c59ffc2f0ec53607eac50da0b2340" @@ -591,6 +630,19 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-http@^3.972.78": + version "3.972.78" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.78.tgz#52ad8d402d5b216183220f787aba61ca40be8fbf" + integrity sha512-3VHknDRngoebsaqyT1iR75b5cBT2zfEtODK6yYmYltljkK1HEAQLs2FEd1IpWqAeOYdMGaG9wikngWLTufY7RA== + dependencies: + "@aws-sdk/core" "^3.978.5" + "@aws-sdk/types" "^3.975.0" + "@smithy/core" "^3.36.1" + "@smithy/fetch-http-handler" "^5.8.0" + "@smithy/node-http-handler" "^4.12.1" + "@smithy/types" "^4.19.0" + tslib "^2.6.2" + "@aws-sdk/credential-provider-ini@^3.972.55": version "3.972.55" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.55.tgz#ddb0c927a6f903cb02ea0f3a943a01c0fc4f3899" @@ -629,6 +681,25 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-ini@^3.973.21": + version "3.973.21" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.21.tgz#c64cf6bd91de0e0619a1d6de09886bee4165e017" + integrity sha512-aLx9hbCX6mGAyv4ZZd8UagJ4RASHVJw6BFDSIyuKmLkqNL6H47EjBPK6qaJ4B5TDV6mc+vMa0SJFYyo4mkvVQg== + dependencies: + "@aws-sdk/core" "^3.978.5" + "@aws-sdk/credential-provider-env" "^3.972.76" + "@aws-sdk/credential-provider-http" "^3.972.78" + "@aws-sdk/credential-provider-login" "^3.972.83" + "@aws-sdk/credential-provider-process" "^3.972.76" + "@aws-sdk/credential-provider-sso" "^3.973.20" + "@aws-sdk/credential-provider-web-identity" "^3.972.82" + "@aws-sdk/nested-clients" "^3.997.50" + "@aws-sdk/types" "^3.975.0" + "@smithy/core" "^3.36.1" + "@smithy/credential-provider-imds" "^4.5.2" + "@smithy/types" "^4.19.0" + tslib "^2.6.2" + "@aws-sdk/credential-provider-login@^3.972.54": version "3.972.54" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.54.tgz#d02f2c8549f48d7f43c757e4566214f05b207675" @@ -653,6 +724,18 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-login@^3.972.83": + version "3.972.83" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.83.tgz#d54c74c4f8de297044ea79c679f7fc984878d44b" + integrity sha512-9bJU6FjRacF4FK/QHf8Dyxhur7B1grRj8C1Yh4IkpoFW2exQflPdCv0VrbemLW9ZctLNle+AE7TETs9KNvuLYg== + dependencies: + "@aws-sdk/core" "^3.978.5" + "@aws-sdk/nested-clients" "^3.997.50" + "@aws-sdk/types" "^3.975.0" + "@smithy/core" "^3.36.1" + "@smithy/types" "^4.19.0" + tslib "^2.6.2" + "@aws-sdk/credential-provider-node@^3.972.50", "@aws-sdk/credential-provider-node@^3.972.52", "@aws-sdk/credential-provider-node@^3.972.57": version "3.972.57" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.57.tgz#0b9f16cb8814d56b86bd23a7610115fd5a2457ea" @@ -687,6 +770,23 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-node@^3.972.88": + version "3.972.88" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.88.tgz#41f751b1711a8afcc6a44a573e54f332ca41adc1" + integrity sha512-AG0KID5qhQPzrFkh5b0Cws1Amytvzo0L0Xj6hMgJXEwD6ZXAqninDhSNAWJiN5zU4YVVZnuidnqwPwYJ04IamA== + dependencies: + "@aws-sdk/credential-provider-env" "^3.972.76" + "@aws-sdk/credential-provider-http" "^3.972.78" + "@aws-sdk/credential-provider-ini" "^3.973.21" + "@aws-sdk/credential-provider-process" "^3.972.76" + "@aws-sdk/credential-provider-sso" "^3.973.20" + "@aws-sdk/credential-provider-web-identity" "^3.972.82" + "@aws-sdk/types" "^3.975.0" + "@smithy/core" "^3.36.1" + "@smithy/credential-provider-imds" "^4.5.2" + "@smithy/types" "^4.19.0" + tslib "^2.6.2" + "@aws-sdk/credential-provider-process@^3.972.48": version "3.972.48" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.48.tgz#7689a62a63317165263555a0fce9379fd560a0c5" @@ -709,6 +809,17 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-process@^3.972.76": + version "3.972.76" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.76.tgz#0155e83b93379083bbb51686fc4662b467f1acc2" + integrity sha512-pdhKFTXv1zPYhh92MTlp0jVbLWvUEV9v+rziuEkoskGGQ2iJskxHTO38nA8UAbT/N4IM3n8OZJ9rIY5IBO15/A== + dependencies: + "@aws-sdk/core" "^3.978.5" + "@aws-sdk/types" "^3.975.0" + "@smithy/core" "^3.36.1" + "@smithy/types" "^4.19.0" + tslib "^2.6.2" + "@aws-sdk/credential-provider-sso@^3.972.54": version "3.972.54" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.54.tgz#4798823741c76292dd035cf5263c09a4d88b82e9" @@ -735,6 +846,19 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-sso@^3.973.20": + version "3.973.20" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.20.tgz#1d5ad65a5d1e86516bc27f134140ee0b82d27e0b" + integrity sha512-DlxrAB3MnjNLx/da7b89W+3DyJQYso/q7Ns15MsST2IV2nkAYQOYTUCaffNSjgrpMP99Dm2mV2jxzcbyF94ZfA== + dependencies: + "@aws-sdk/core" "^3.978.5" + "@aws-sdk/nested-clients" "^3.997.50" + "@aws-sdk/token-providers" "3.1155.0" + "@aws-sdk/types" "^3.975.0" + "@smithy/core" "^3.36.1" + "@smithy/types" "^4.19.0" + tslib "^2.6.2" + "@aws-sdk/credential-provider-web-identity@^3.972.54": version "3.972.54" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.54.tgz#226d686715396b711c13a62285b229f84b1a3fa7" @@ -759,6 +883,18 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-web-identity@^3.972.82": + version "3.972.82" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.82.tgz#0919b59684666ff0c5649da7bb48839c99b277c1" + integrity sha512-LUo46IqPTJ4xKBsrLuoaxiv/D2GTBCcFDMD1y3jKIqPR/p7Rw14RJFZ0iRWftqZtyLPQX9JMGnSi3mBu4y/pNA== + dependencies: + "@aws-sdk/core" "^3.978.5" + "@aws-sdk/nested-clients" "^3.997.50" + "@aws-sdk/types" "^3.975.0" + "@smithy/core" "^3.36.1" + "@smithy/types" "^4.19.0" + tslib "^2.6.2" + "@aws-sdk/middleware-flexible-checksums@^3.974.32": version "3.974.32" resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.32.tgz#fb4591d82c7b73c1dae350c49015a0c34dba6ccf" @@ -809,6 +945,20 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/nested-clients@^3.997.50": + version "3.997.50" + resolved "https://registry.yarnpkg.com/@aws-sdk/nested-clients/-/nested-clients-3.997.50.tgz#fcf3263fc47040aac27990970017d737a2a21683" + integrity sha512-nKb8qu4QsnpZTq/6JIXc72mzuzfh7BK4sUQkpQznPec2z4W7Ob2xYkcM4i5e1yGTBnbJVeFv/0sf3jcz85hclA== + dependencies: + "@aws-sdk/core" "^3.978.5" + "@aws-sdk/signature-v4-multi-region" "^3.996.49" + "@aws-sdk/types" "^3.975.0" + "@smithy/core" "^3.36.1" + "@smithy/fetch-http-handler" "^5.8.0" + "@smithy/node-http-handler" "^4.12.1" + "@smithy/types" "^4.19.0" + tslib "^2.6.2" + "@aws-sdk/signature-v4-multi-region@^3.996.32", "@aws-sdk/signature-v4-multi-region@^3.996.35": version "3.996.35" resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.35.tgz#2994b9f33e84b9c74392b7495f89e5c958bda503" @@ -829,6 +979,16 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/signature-v4-multi-region@^3.996.49": + version "3.996.49" + resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.49.tgz#2e7d874620cc19922f613f8f1e0c4c0cb38b9598" + integrity sha512-nvuAZNRrRRVCiARRKsYlQ0VnXiEQiq7vXPH+UA+LSo+kN3Djl9eo/V2r0udkw85PnsNshmv2Em1clld5M3j67w== + dependencies: + "@aws-sdk/types" "^3.975.0" + "@smithy/signature-v4" "^5.7.4" + "@smithy/types" "^4.19.0" + tslib "^2.6.2" + "@aws-sdk/token-providers@3.1071.0": version "3.1071.0" resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.1071.0.tgz#3302dafa1acdb097002496700160881b161a0148" @@ -853,6 +1013,18 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/token-providers@3.1155.0": + version "3.1155.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.1155.0.tgz#201abec26c2815fb60bce72583d5fc30e4b63d36" + integrity sha512-iIcQnYLhWuq9qOLQbIWFrxF3PF0svVeZvHnUS4DUnO4zUFmFHIyGyjqrntXG+e/5PUgfavV0pYfDIKHxTzN2LA== + dependencies: + "@aws-sdk/core" "^3.978.5" + "@aws-sdk/nested-clients" "^3.997.50" + "@aws-sdk/types" "^3.975.0" + "@smithy/core" "^3.36.1" + "@smithy/types" "^4.19.0" + tslib "^2.6.2" + "@aws-sdk/types@^3.1.0", "@aws-sdk/types@^3.222.0", "@aws-sdk/types@^3.973.10", "@aws-sdk/types@^3.973.11", "@aws-sdk/types@^3.973.13": version "3.973.13" resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.973.13.tgz#c076f611e94394a49c1bc1aeb64371ef6db4b3da" @@ -869,6 +1041,14 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/types@^3.975.0": + version "3.975.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.975.0.tgz#15fb614cf1877ee41c5f08edfd21818dc90edd1d" + integrity sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw== + dependencies: + "@smithy/types" "^4.19.0" + tslib "^2.6.2" + "@aws-sdk/util-locate-window@^3.0.0": version "3.965.5" resolved "https://registry.yarnpkg.com/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz#e30e6ff2aff6436209ed42c765dec2d2a48df7c0" @@ -900,6 +1080,14 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@aws-sdk/xml-builder@^3.972.41": + version "3.972.41" + resolved "https://registry.yarnpkg.com/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz#d3154d8893c3a85f58ce629b70a17ce70e0a1137" + integrity sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ== + dependencies: + "@smithy/types" "^4.19.0" + tslib "^2.6.2" + "@aws/lambda-invoke-store@^0.2.2": version "0.2.4" resolved "https://registry.yarnpkg.com/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz#802f6a50f6b6589063ef63ba8acdee86fcb9f395" @@ -4396,6 +4584,14 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@smithy/core@^3.33.2", "@smithy/core@^3.33.3", "@smithy/core@^3.35.0", "@smithy/core@^3.36.1": + version "3.36.2" + resolved "https://registry.yarnpkg.com/@smithy/core/-/core-3.36.2.tgz#dc8348dbdd9604a297e93ab791266d6511c2038c" + integrity sha512-oRw0rB9QSUiCZfEs23G5F4Af+b2rQt1lItIICWu5IejFNd+uPM8SWqiKLMpQ6bX1Q7slsLbjuoE2niErFtkVPQ== + dependencies: + "@smithy/types" "^4.19.0" + tslib "^2.6.2" + "@smithy/credential-provider-imds@^4.3.7": version "4.3.8" resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.8.tgz#d22f0ed81bac46017fa2f8f848ad89ab506892bb" @@ -4414,6 +4610,15 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@smithy/credential-provider-imds@^4.5.2": + version "4.5.2" + resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz#da3ea9636b5566b36b0761382d841a6d8fdde14f" + integrity sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg== + dependencies: + "@smithy/core" "^3.33.2" + "@smithy/types" "^4.17.2" + tslib "^2.6.2" + "@smithy/fetch-http-handler@^5.4.6": version "5.4.6" resolved "https://registry.yarnpkg.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.6.tgz#745cdf8b6c333632672f8f48360bde04b8955b47" @@ -4432,6 +4637,15 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@smithy/fetch-http-handler@^5.8.0": + version "5.8.0" + resolved "https://registry.yarnpkg.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz#349464e31682d739a74efa42362756be49eb2883" + integrity sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA== + dependencies: + "@smithy/core" "^3.33.3" + "@smithy/types" "^4.18.0" + tslib "^2.6.2" + "@smithy/is-array-buffer@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz#f84f0d9f9a36601a9ca9381688bd1b726fd39111" @@ -4455,6 +4669,15 @@ "@smithy/util-utf8" "^2.0.0" tslib "^2.5.0" +"@smithy/node-http-handler@^4.12.1": + version "4.12.1" + resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz#e4da9379d228517ba85845081e4e6758ca315c5d" + integrity sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw== + dependencies: + "@smithy/core" "^3.33.3" + "@smithy/types" "^4.18.0" + tslib "^2.6.2" + "@smithy/node-http-handler@^4.7.6": version "4.7.7" resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.7.7.tgz#cd0669141c5ee10c8b0516b652326ca4c28d643a" @@ -4491,6 +4714,15 @@ "@smithy/types" "^4.16.1" tslib "^2.6.2" +"@smithy/signature-v4@^5.7.4": + version "5.7.4" + resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.7.4.tgz#f188be5238ddb5c5b3117f6fb75d36a38f8b033f" + integrity sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ== + dependencies: + "@smithy/core" "^3.35.0" + "@smithy/types" "^4.19.0" + tslib "^2.6.2" + "@smithy/types@^2.3.1": version "2.12.0" resolved "https://registry.yarnpkg.com/@smithy/types/-/types-2.12.0.tgz#c44845f8ba07e5e8c88eda5aed7e6a0c462da041" @@ -4519,6 +4751,13 @@ dependencies: tslib "^2.6.2" +"@smithy/types@^4.17.2", "@smithy/types@^4.18.0", "@smithy/types@^4.19.0": + version "4.19.0" + resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.19.0.tgz#a0dd9c9d7b2a070fc7c3d69efedea8404b3646be" + integrity sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q== + dependencies: + tslib "^2.6.2" + "@smithy/util-base64@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@smithy/util-base64/-/util-base64-3.0.0.tgz#f7a9a82adf34e27a72d0719395713edf0e493017" From 5b9243ce6dd9433b105c43c9390e7e866c21d2f3 Mon Sep 17 00:00:00 2001 From: yurikakan Date: Tue, 27 Oct 2026 03:32:44 -0400 Subject: [PATCH 03/11] feat(notifications): add bulk email endpoint with scheduled sends - 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 --- apps/backend/src/app.module.ts | 2 + .../src/notifications/awsScheduler.factory.ts | 26 ++ .../dtos/create-bulk-notification.dto.ts | 41 +++ .../notifications.controller.spec.ts | 64 ++++ .../notifications/notifications.controller.ts | 69 +++++ .../notifications.dispatch.guard.spec.ts | 50 ++++ .../notifications.dispatch.guard.ts | 59 ++++ .../notifications.module.spec.ts | 83 ++++++ .../src/notifications/notifications.module.ts | 50 ++++ .../notifications.service.spec.ts | 279 ++++++++++++++++++ .../notifications/notifications.service.ts | 181 ++++++++++++ 11 files changed, 904 insertions(+) create mode 100644 apps/backend/src/notifications/awsScheduler.factory.ts create mode 100644 apps/backend/src/notifications/dtos/create-bulk-notification.dto.ts create mode 100644 apps/backend/src/notifications/notifications.controller.spec.ts create mode 100644 apps/backend/src/notifications/notifications.controller.ts create mode 100644 apps/backend/src/notifications/notifications.dispatch.guard.spec.ts create mode 100644 apps/backend/src/notifications/notifications.dispatch.guard.ts create mode 100644 apps/backend/src/notifications/notifications.module.spec.ts create mode 100644 apps/backend/src/notifications/notifications.module.ts create mode 100644 apps/backend/src/notifications/notifications.service.spec.ts create mode 100644 apps/backend/src/notifications/notifications.service.ts diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index d23aae40e..7e9f10547 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -7,6 +7,7 @@ import { RecommendationsModule } from './recommendations/recommendations.module' import { MatchesModule } from './matches/matches.module'; import { CoordinatorsModule } from './coordinators/coordinators.module'; import { VolunteersModule } from './volunteers/volunteers.module'; +import { NotificationsModule } from './notifications/notifications.module'; @Module({ imports: [ @@ -24,6 +25,7 @@ import { VolunteersModule } from './volunteers/volunteers.module'; MatchesModule, CoordinatorsModule, VolunteersModule, + NotificationsModule, ], }) export class AppModule {} diff --git a/apps/backend/src/notifications/awsScheduler.factory.ts b/apps/backend/src/notifications/awsScheduler.factory.ts new file mode 100644 index 000000000..4d57a6684 --- /dev/null +++ b/apps/backend/src/notifications/awsScheduler.factory.ts @@ -0,0 +1,26 @@ +import { Provider } from '@nestjs/common'; +import { SchedulerClient } from '@aws-sdk/client-scheduler'; + +export const AMAZON_SCHEDULER_CLIENT = 'AMAZON_SCHEDULER_CLIENT'; + +/** + * Factory that produces a new instance of the EventBridge Scheduler client. + * Reads region and credentials from process.env at injection time and + * passes them explicitly to the client, matching the SES client factory. + * + * Unlike that factory there is no disabled branch returning a dummy client: the + * empty-string fallbacks already make this constructible with no AWS config, and + * NotificationsService rejects a scheduled send before touching the client when + * the target and role ARNs are unset. + */ +export const AmazonSchedulerClientFactory: Provider = { + provide: AMAZON_SCHEDULER_CLIENT, + useFactory: () => + new SchedulerClient({ + region: process.env.AWS_REGION ?? '', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? '', + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? '', + }, + }), +}; diff --git a/apps/backend/src/notifications/dtos/create-bulk-notification.dto.ts b/apps/backend/src/notifications/dtos/create-bulk-notification.dto.ts new file mode 100644 index 000000000..6ccffd292 --- /dev/null +++ b/apps/backend/src/notifications/dtos/create-bulk-notification.dto.ts @@ -0,0 +1,41 @@ +import { Type } from 'class-transformer'; +import { + IsArray, + IsDate, + IsEnum, + IsOptional, + Matches, + MaxLength, + MinDate, +} from 'class-validator'; +import { IsNonEmptyString } from '../../utils/decorators/is-non-empty-string.decorator'; +import { FosterType } from '../../volunteers/volunteers.types'; + +/** + * input shape for POST /notifications. + */ +export class CreateBulkNotificationDto { + @IsNonEmptyString() + @MaxLength(255) + // Reject carriage return (\r) and line feed (\n) to prevent MIME header injection + @Matches(/^[^\r\n]+$/, { + message: 'subject must not contain newlines', + }) + subject!: string; + + @IsNonEmptyString() + @MaxLength(5000) + body!: string; + + // Omitted/empty = every active Volunteer regardless of foster type. + @IsOptional() + @IsArray() + @IsEnum(FosterType, { each: true }) + filters?: FosterType[]; + + @IsOptional() + @Type(() => Date) + @IsDate() + @MinDate(() => new Date(), { message: 'sendAt must be in the future' }) + sendAt?: Date; +} diff --git a/apps/backend/src/notifications/notifications.controller.spec.ts b/apps/backend/src/notifications/notifications.controller.spec.ts new file mode 100644 index 000000000..0ee5563cc --- /dev/null +++ b/apps/backend/src/notifications/notifications.controller.spec.ts @@ -0,0 +1,64 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotificationsController } from './notifications.controller'; +import { NotificationsService } from './notifications.service'; +import { FosterType } from '../volunteers/volunteers.types'; + +describe('NotificationsController', () => { + let controller: NotificationsController; + let service: { createBulkNotification: jest.Mock; sendNow: jest.Mock }; + + const body = { + subject: 'Fosters needed', + body: '

Please help.

', + filters: [FosterType.DOG], + }; + + beforeEach(async () => { + service = { + createBulkNotification: jest.fn().mockResolvedValue(undefined), + sendNow: jest.fn().mockResolvedValue(undefined), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [NotificationsController], + providers: [{ provide: NotificationsService, useValue: service }], + }).compile(); + + controller = module.get(NotificationsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('createBulkNotification', () => { + it('passes the body to the service and returns nothing', async () => { + await expect( + controller.createBulkNotification(body), + ).resolves.toBeUndefined(); + + expect(service.createBulkNotification).toHaveBeenCalledWith(body); + }); + + it('propagates service failures', async () => { + service.createBulkNotification.mockRejectedValue(new Error('boom')); + + await expect(controller.createBulkNotification(body)).rejects.toThrow( + 'boom', + ); + }); + }); + + describe('dispatchNotification', () => { + // The wait already happened in Scheduler, so dispatch sends rather than + // going back through createBulkNotification and rescheduling. + it('sends immediately without creating another schedule', async () => { + await expect( + controller.dispatchNotification(body), + ).resolves.toBeUndefined(); + + expect(service.sendNow).toHaveBeenCalledWith(body); + expect(service.createBulkNotification).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/backend/src/notifications/notifications.controller.ts b/apps/backend/src/notifications/notifications.controller.ts new file mode 100644 index 000000000..f8cd11827 --- /dev/null +++ b/apps/backend/src/notifications/notifications.controller.ts @@ -0,0 +1,69 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Post, + UseGuards, +} from '@nestjs/common'; +import { + ApiExcludeEndpoint, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { NotificationsService } from './notifications.service'; +import { CreateBulkNotificationDto } from './dtos/create-bulk-notification.dto'; +import { NotificationsDispatchGuard } from './notifications.dispatch.guard'; + +@ApiTags('Notifications') +// @ApiBearerAuth() +@Controller('notifications') +export class NotificationsController { + constructor(private notificationsService: NotificationsService) {} + + // TODO: role/cognito auth: possible considerations of how roles are modelled (cognito groups vs db role) + @Post() + @HttpCode(HttpStatus.ACCEPTED) + @ApiOperation({ + summary: + 'Email all or every active Volunteer matching the given foster types', + }) + @ApiResponse({ + status: HttpStatus.ACCEPTED, + description: + 'The notification was sent, or scheduled when sendAt was given', + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'The request body failed validation', + }) + @ApiResponse({ + status: HttpStatus.INTERNAL_SERVER_ERROR, + description: 'The AWS Scheduler or SES sender email is not configured', + }) + async createBulkNotification( + @Body() body: CreateBulkNotificationDto, + ): Promise { + await this.notificationsService.createBulkNotification(body); + } + + /** + * Sends a notification whose scheduled time has arrived. + * + * Called only by the scheduled-notification Lambda + * TODO: implement auth: lambdas don't use cognito, so it can't call our own endpoints (which will have cognito auth on them) + * recipients are resolved at call time & not at schedule time, so Volunteers who went inactive in the meantime are left out + * + * Retry after a partial send repeats earlier batches, sends at least once + */ + @Post('send-scheduled') + @UseGuards(NotificationsDispatchGuard) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiExcludeEndpoint() + async dispatchNotification( + @Body() body: CreateBulkNotificationDto, + ): Promise { + return this.notificationsService.sendNow(body); + } +} diff --git a/apps/backend/src/notifications/notifications.dispatch.guard.spec.ts b/apps/backend/src/notifications/notifications.dispatch.guard.spec.ts new file mode 100644 index 000000000..42055d7b6 --- /dev/null +++ b/apps/backend/src/notifications/notifications.dispatch.guard.spec.ts @@ -0,0 +1,50 @@ +import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { + DISPATCH_SECRET_HEADER, + NotificationsDispatchGuard, +} from './notifications.dispatch.guard'; + +const SECRET = 'a-very-secret-value'; + +const contextWith = (headers: Record) => + ({ + switchToHttp: () => ({ getRequest: () => ({ headers }) }), + } as unknown as ExecutionContext); + +describe('NotificationsDispatchGuard', () => { + let guard: NotificationsDispatchGuard; + + beforeEach(() => { + process.env.NOTIFICATIONS_DISPATCH_SECRET = SECRET; + guard = new NotificationsDispatchGuard(); + }); + + it('allows a request presenting the configured secret', () => { + expect( + guard.canActivate(contextWith({ [DISPATCH_SECRET_HEADER]: SECRET })), + ).toBe(true); + }); + + it.each([ + ['a wrong secret', { [DISPATCH_SECRET_HEADER]: 'nope' }], + [ + 'a secret of matching length', + { [DISPATCH_SECRET_HEADER]: 'b'.repeat(SECRET.length) }, + ], + ['no header at all', {}], + ['a non-string header', { [DISPATCH_SECRET_HEADER]: ['a', 'b'] }], + ])('rejects %s', (_label, headers) => { + expect(() => guard.canActivate(contextWith(headers))).toThrow( + UnauthorizedException, + ); + }); + + // An unset secret must never mean "let everyone through". + it('fails closed when no secret is configured', () => { + delete process.env.NOTIFICATIONS_DISPATCH_SECRET; + + expect(() => + guard.canActivate(contextWith({ [DISPATCH_SECRET_HEADER]: SECRET })), + ).toThrow(UnauthorizedException); + }); +}); diff --git a/apps/backend/src/notifications/notifications.dispatch.guard.ts b/apps/backend/src/notifications/notifications.dispatch.guard.ts new file mode 100644 index 000000000..852d0b96c --- /dev/null +++ b/apps/backend/src/notifications/notifications.dispatch.guard.ts @@ -0,0 +1,59 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + Logger, + UnauthorizedException, +} from '@nestjs/common'; +import { timingSafeEqual } from 'crypto'; +import { Request } from 'express'; + +export const DISPATCH_SECRET_HEADER = 'x-notifications-dispatch-secret'; + +/** + * Guards the dispatch route so only the scheduled-notification Lambda can send. + * + * The Lambda has no Cognito account, so it identifies itself with a shared secret + * instead. Rejects everything while the secret is unset. + */ +@Injectable() +export class NotificationsDispatchGuard implements CanActivate { + private readonly logger = new Logger(NotificationsDispatchGuard.name); + + /** + * Checks the caller's secret against NOTIFICATIONS_DISPATCH_SECRET. + * + * @param context - The current execution context provided by NestJS. + * @returns `true` when the presented secret matches. + * @throws {UnauthorizedException} If the secret is wrong, missing, or unconfigured. + */ + canActivate(context: ExecutionContext): boolean { + const expected = process.env.NOTIFICATIONS_DISPATCH_SECRET; + + // Fail closed: an unset secret must never mean "let everyone through". + if (!expected) { + this.logger.error( + 'NOTIFICATIONS_DISPATCH_SECRET is not configured. Rejecting all dispatch requests.', + ); + throw new UnauthorizedException(); + } + + const request = context.switchToHttp().getRequest(); + const provided = request.headers[DISPATCH_SECRET_HEADER]; + + if (typeof provided !== 'string' || !this.matches(provided, expected)) { + this.logger.warn('Rejected a dispatch request with an invalid secret'); + throw new UnauthorizedException(); + } + + return true; + } + + // Constant-time so timings can't leak the secret. Length first because + // timingSafeEqual throws on a mismatch. + private matches(provided: string, expected: string): boolean { + const a = Buffer.from(provided); + const b = Buffer.from(expected); + return a.length === b.length && timingSafeEqual(a, b); + } +} diff --git a/apps/backend/src/notifications/notifications.module.spec.ts b/apps/backend/src/notifications/notifications.module.spec.ts new file mode 100644 index 000000000..238ab7f6d --- /dev/null +++ b/apps/backend/src/notifications/notifications.module.spec.ts @@ -0,0 +1,83 @@ +import { NotificationsModule } from './notifications.module'; + +describe('NotificationsModule', () => { + const REQUIRED_FOR_SCHEDULING = [ + 'AWS_REGION', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_NOTIFICATION_DISPATCH_LAMBDA_ARN', + 'AWS_SCHEDULER_ROLE_ARN', + 'NOTIFICATIONS_DISPATCH_SECRET', + ] as const; + + const originalEnv: Record = {}; + let module: NotificationsModule; + let logSpy: jest.SpyInstance; + + beforeEach(() => { + for (const name of REQUIRED_FOR_SCHEDULING) { + originalEnv[name] = process.env[name]; + } + + // Default to a fully-configured setup; individual tests override. + process.env.AWS_REGION = 'us-east-2'; + process.env.AWS_ACCESS_KEY_ID = 'test-access-key-id'; + process.env.AWS_SECRET_ACCESS_KEY = 'test-secret-access-key'; + process.env.AWS_NOTIFICATION_DISPATCH_LAMBDA_ARN = 'arn:aws:lambda:::fn'; + process.env.AWS_SCHEDULER_ROLE_ARN = 'arn:aws:iam:::role/scheduler'; + process.env.NOTIFICATIONS_DISPATCH_SECRET = 'test-secret'; + + module = new NotificationsModule(); + logSpy = jest + .spyOn(module['logger'], 'log') + .mockImplementation(() => undefined); + }); + + afterEach(() => { + for (const name of REQUIRED_FOR_SCHEDULING) { + if (originalEnv[name] === undefined) { + delete process.env[name]; + } else { + process.env[name] = originalEnv[name]; + } + } + jest.restoreAllMocks(); + }); + + it('reports scheduling as enabled when every var is set', () => { + module.onModuleInit(); + + expect(logSpy).toHaveBeenCalledWith('Scheduled notifications enabled'); + }); + + it.each(REQUIRED_FOR_SCHEDULING)('names %s when it is missing', (name) => { + delete process.env[name]; + + module.onModuleInit(); + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining(`missing env vars (${name})`), + ); + }); + + // Whitespace is as unusable as unset, and getMissingEnvVars treats it that way. + it('counts a whitespace-only var as missing', () => { + process.env.AWS_SCHEDULER_ROLE_ARN = ' '; + + module.onModuleInit(); + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('AWS_SCHEDULER_ROLE_ARN'), + ); + }); + + it('says immediate sends still work when scheduling is unconfigured', () => { + delete process.env.AWS_SCHEDULER_ROLE_ARN; + + module.onModuleInit(); + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Immediate sends still work'), + ); + }); +}); diff --git a/apps/backend/src/notifications/notifications.module.ts b/apps/backend/src/notifications/notifications.module.ts new file mode 100644 index 000000000..f8135cd6e --- /dev/null +++ b/apps/backend/src/notifications/notifications.module.ts @@ -0,0 +1,50 @@ +import { Logger, Module, OnModuleInit } from '@nestjs/common'; +import { NotificationsController } from './notifications.controller'; +import { NotificationsService } from './notifications.service'; +import { AmazonSchedulerClientFactory } from './awsScheduler.factory'; +import { VolunteersModule } from '../volunteers/volunteers.module'; +import { AWSSESModule } from '../aws/ses/email.module'; +import { getMissingEnvVars } from '../utils/env'; + +// Env vars needed only to schedule a notification for a future time. Sending one +// immediately needs none of these, just the SES config the AWSSESModule checks. +// AWS_REGION and the credentials are the shared AWS ones (also used by S3 and SES). +const REQUIRED_ENV_VARS_FOR_SCHEDULING = [ + 'AWS_REGION', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_NOTIFICATION_DISPATCH_LAMBDA_ARN', + 'AWS_SCHEDULER_ROLE_ARN', + 'NOTIFICATIONS_DISPATCH_SECRET', +] as const; + +@Module({ + imports: [VolunteersModule, AWSSESModule], + controllers: [NotificationsController], + providers: [NotificationsService, AmazonSchedulerClientFactory], +}) +export class NotificationsModule implements OnModuleInit { + private readonly logger = new Logger(NotificationsModule.name); + + /** + * Reports at startup whether scheduled notifications are usable. + * + * There is no enabled flag: having the vars set is what turns scheduling on. This + * surfaces a half-configured environment in the boot log rather than as a 500 the + * first time a coordinator picks a send time. + */ + onModuleInit(): void { + const missing = getMissingEnvVars(REQUIRED_ENV_VARS_FOR_SCHEDULING); + + if (missing.length === 0) { + this.logger.log('Scheduled notifications enabled'); + return; + } + + this.logger.log( + `Scheduled notifications disabled: missing env vars (${missing.join( + ', ', + )}). Immediate sends still work; POST /notifications with a sendAt will fail.`, + ); + } +} diff --git a/apps/backend/src/notifications/notifications.service.spec.ts b/apps/backend/src/notifications/notifications.service.spec.ts new file mode 100644 index 000000000..3254efbcb --- /dev/null +++ b/apps/backend/src/notifications/notifications.service.spec.ts @@ -0,0 +1,279 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { InternalServerErrorException } from '@nestjs/common'; +import { NotificationsService } from './notifications.service'; +import { AMAZON_SCHEDULER_CLIENT } from './awsScheduler.factory'; +import { EmailsService } from '../aws/ses/email.service'; +import { VolunteersService } from '../volunteers/volunteers.service'; +import { FosterVolunteer } from '../volunteers/volunteers.entity'; +import { FosterType } from '../volunteers/volunteers.types'; + +const SENDER = 'sender@mspca.org'; + +/** Builds `count` volunteers with distinct emails. */ +const volunteers = (count: number) => + Array.from( + { length: count }, + (_, i) => ({ email: `volunteer${i}@example.com` } as FosterVolunteer), + ); + +describe('NotificationsService', () => { + let service: NotificationsService; + let emailsService: { sendEmail: jest.Mock }; + let volunteersService: { findActiveByFosterTypes: jest.Mock }; + let scheduler: { send: jest.Mock }; + + const dto = { subject: 'Fosters needed', body: '

Please help.

' }; + + /** The bccEmails array of each sendEmail call, in order. */ + const sentBatches = (): string[][] => + emailsService.sendEmail.mock.calls.map((c) => c[0].bccEmails); + + beforeEach(async () => { + process.env.AWS_SES_SENDER_EMAIL = SENDER; + + process.env.AWS_NOTIFICATION_DISPATCH_LAMBDA_ARN = 'arn:aws:lambda:::fn'; + process.env.AWS_SCHEDULER_ROLE_ARN = 'arn:aws:iam:::role/scheduler'; + + emailsService = { sendEmail: jest.fn().mockResolvedValue(undefined) }; + scheduler = { send: jest.fn().mockResolvedValue({}) }; + volunteersService = { + findActiveByFosterTypes: jest.fn().mockResolvedValue(volunteers(1)), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NotificationsService, + { provide: EmailsService, useValue: emailsService }, + { provide: VolunteersService, useValue: volunteersService }, + { provide: AMAZON_SCHEDULER_CLIENT, useValue: scheduler }, + ], + }).compile(); + + service = module.get(NotificationsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('sendNow', () => { + it('bccs the recipients and puts the sender in the to slot', async () => { + await service.sendNow(dto); + + expect(emailsService.sendEmail).toHaveBeenCalledWith({ + toEmail: SENDER, + subject: 'Fosters needed', + bodyHtml: '

Please help.

', + bccEmails: ['volunteer0@example.com'], + }); + }); + + it('passes the filters through to the volunteer lookup', async () => { + await service.sendNow({ + ...dto, + filters: [FosterType.DOG, FosterType.CAT], + }); + + expect(volunteersService.findActiveByFosterTypes).toHaveBeenCalledWith([ + FosterType.DOG, + FosterType.CAT, + ]); + }); + + it('looks up every active volunteer when no filters are given', async () => { + await service.sendNow(dto); + + expect(volunteersService.findActiveByFosterTypes).toHaveBeenCalledWith( + [], + ); + }); + + // SES rejects a message with more than 50 recipients across To, Cc and Bcc, + // and the sender takes one of those slots, so a batch may hold at most 49. + it.each([ + [48, [48]], + [49, [49]], + [50, [49, 1]], + [98, [49, 49]], + [99, [49, 49, 1]], + ])('splits %i recipients into batches of %j', async (count, expected) => { + volunteersService.findActiveByFosterTypes.mockResolvedValue( + volunteers(count), + ); + + await service.sendNow(dto); + + expect(sentBatches().map((b) => b.length)).toEqual(expected); + }); + + it('sends to every recipient exactly once across the batches', async () => { + volunteersService.findActiveByFosterTypes.mockResolvedValue( + volunteers(60), + ); + + await service.sendNow(dto); + + expect(sentBatches().flat()).toEqual(volunteers(60).map((v) => v.email)); + }); + + it('deduplicates volunteers sharing an email address', async () => { + volunteersService.findActiveByFosterTypes.mockResolvedValue([ + { email: 'shared@example.com' }, + { email: 'shared@example.com' }, + ] as FosterVolunteer[]); + + await service.sendNow(dto); + + expect(sentBatches()).toEqual([['shared@example.com']]); + }); + + // SendEmailDTO caps each Bcc address at 255 characters as well as validating it, + // so an overlong address would reject its whole batch of 49. + it('drops an address over 255 characters', async () => { + volunteersService.findActiveByFosterTypes.mockResolvedValue([ + { email: 'good@example.com' }, + { email: `${'a'.repeat(250)}@example.com` }, + ] as FosterVolunteer[]); + + await service.sendNow(dto); + + expect(sentBatches()).toEqual([['good@example.com']]); + }); + + it('sends nothing when every address is unusable', async () => { + volunteersService.findActiveByFosterTypes.mockResolvedValue([ + { email: 'not-an-email' }, + ] as FosterVolunteer[]); + + await service.sendNow(dto); + + expect(emailsService.sendEmail).not.toHaveBeenCalled(); + }); + + it('treats emails differing only in case as duplicates', async () => { + volunteersService.findActiveByFosterTypes.mockResolvedValue([ + { email: 'dup@example.com' }, + { email: 'DUP@example.com' }, + ] as FosterVolunteer[]); + + await service.sendNow(dto); + + expect(sentBatches()).toEqual([['dup@example.com']]); + }); + + it('skips invalid addresses instead of failing their batch', async () => { + volunteersService.findActiveByFosterTypes.mockResolvedValue([ + { email: 'good@example.com' }, + { email: 'not-an-email' }, + ] as FosterVolunteer[]); + + await service.sendNow(dto); + + expect(sentBatches()).toEqual([['good@example.com']]); + }); + + it('sends the later batches when an earlier one fails', async () => { + volunteersService.findActiveByFosterTypes.mockResolvedValue( + volunteers(98), + ); + emailsService.sendEmail + .mockRejectedValueOnce(new Error('SES rejected the send')) + .mockResolvedValueOnce(undefined); + + await expect(service.sendNow(dto)).resolves.toBeUndefined(); + + expect(emailsService.sendEmail).toHaveBeenCalledTimes(2); + }); + + it('sends nothing when no volunteers match', async () => { + volunteersService.findActiveByFosterTypes.mockResolvedValue([]); + + await service.sendNow(dto); + + expect(emailsService.sendEmail).not.toHaveBeenCalled(); + }); + + it('fails when the sender address is not configured', async () => { + delete process.env.AWS_SES_SENDER_EMAIL; + + await expect(service.sendNow(dto)).rejects.toThrow( + InternalServerErrorException, + ); + }); + }); + + describe('createBulkNotification', () => { + const sendAt = new Date('2030-01-01T09:30:00.000Z'); + + /** The CreateScheduleCommand input from the single scheduler call. */ + const scheduleInput = () => scheduler.send.mock.calls[0][0].input; + + it('sends immediately and creates no schedule when sendAt is absent', async () => { + await expect( + service.createBulkNotification(dto), + ).resolves.toBeUndefined(); + + expect(emailsService.sendEmail).toHaveBeenCalled(); + expect(scheduler.send).not.toHaveBeenCalled(); + }); + + it('creates a schedule and emails nobody when sendAt is given', async () => { + await expect( + service.createBulkNotification({ ...dto, sendAt }), + ).resolves.toBeUndefined(); + + expect(scheduler.send).toHaveBeenCalledTimes(1); + expect(emailsService.sendEmail).not.toHaveBeenCalled(); + expect(volunteersService.findActiveByFosterTypes).not.toHaveBeenCalled(); + }); + + // at() rejects the trailing Z and milliseconds that toISOString() emits. + it('formats sendAt as an at() expression in UTC', async () => { + await service.createBulkNotification({ ...dto, sendAt }); + + expect(scheduleInput()).toMatchObject({ + ScheduleExpression: 'at(2030-01-01T09:30:00)', + ScheduleExpressionTimezone: 'UTC', + FlexibleTimeWindow: { Mode: 'OFF' }, + ActionAfterCompletion: 'DELETE', + }); + }); + + // Scheduler's default is up to 185 retries over 24 hours, which would keep + // re-posting to the dispatch route all day when the config is simply wrong. + it('bounds the retries instead of taking the 24 hour default', async () => { + await service.createBulkNotification({ ...dto, sendAt }); + + expect(scheduleInput().Target.RetryPolicy).toEqual({ + MaximumRetryAttempts: 2, + MaximumEventAgeInSeconds: 600, + }); + }); + + // Resolving recipients at send time is what keeps inactive Volunteers out of + // a notification scheduled while they were still active. + it('stores the filters in the payload, never the resolved recipients', async () => { + await service.createBulkNotification({ + ...dto, + sendAt, + filters: [FosterType.DOG], + }); + + expect(JSON.parse(scheduleInput().Target.Input)).toEqual({ + subject: 'Fosters needed', + body: '

Please help.

', + filters: [FosterType.DOG], + }); + }); + + it('fails when the scheduler target is not configured', async () => { + delete process.env.AWS_NOTIFICATION_DISPATCH_LAMBDA_ARN; + + await expect( + service.createBulkNotification({ ...dto, sendAt }), + ).rejects.toThrow(InternalServerErrorException); + + expect(scheduler.send).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/backend/src/notifications/notifications.service.ts b/apps/backend/src/notifications/notifications.service.ts new file mode 100644 index 000000000..2703ae407 --- /dev/null +++ b/apps/backend/src/notifications/notifications.service.ts @@ -0,0 +1,181 @@ +import { + Inject, + Injectable, + InternalServerErrorException, + Logger, +} from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { isEmail } from 'class-validator'; +import { + ActionAfterCompletion, + CreateScheduleCommand, + FlexibleTimeWindowMode, + SchedulerClient, +} from '@aws-sdk/client-scheduler'; +import { EmailsService } from '../aws/ses/email.service'; +import { VolunteersService } from '../volunteers/volunteers.service'; +import { CreateBulkNotificationDto } from './dtos/create-bulk-notification.dto'; +import { AMAZON_SCHEDULER_CLIENT } from './awsScheduler.factory'; + +// SES caps a message at 50 recipients across To, Cc and Bcc combined, and the +// sender always occupies the To slot, so only 49 are left for Bcc. +const BCC_BATCH_SIZE = 49; + +// Mirrors SendEmailDTO's @Length(1, 255) on each Bcc address. +const MAX_EMAIL_LENGTH = 255; + +@Injectable() +export class NotificationsService { + private readonly logger = new Logger(NotificationsService.name); + + constructor( + private volunteersService: VolunteersService, + private emailsService: EmailsService, + @Inject(AMAZON_SCHEDULER_CLIENT) + private readonly scheduler: SchedulerClient, + ) {} + + /** + * Sends a bulk notification immediately, or schedules it when sendAt is set. + * + * @param dto - The subject, HTML body, foster type filters, and optional send time. + * @throws {InternalServerErrorException} If the AWS configuration needed for the + * chosen path is missing. + */ + async createBulkNotification(dto: CreateBulkNotificationDto): Promise { + if (dto.sendAt) { + return this.schedule(dto, dto.sendAt); + } + + return this.sendNow(dto); + } + + /** + * Hands the notification to EventBridge Scheduler, which invokes the dispatch + * Lambda at `sendAt`. + * + * The notification itself is stored in the schedule's payload rather than in our + * database, and the schedule deletes itself once it has fired. Only the filters + * are stored, never the resolved recipients, so Volunteers whose status changed before + * `sendAt` are correctly left out when the dispatch actually runs. + * + * @param content - The subject, HTML body, and foster type filters. + * @param sendAt - When the notification should go out. + * @throws {InternalServerErrorException} If the Scheduler target or role is not configured. + */ + private async schedule( + content: CreateBulkNotificationDto, + sendAt: Date, + ): Promise { + const targetArn = process.env.AWS_NOTIFICATION_DISPATCH_LAMBDA_ARN; + const roleArn = process.env.AWS_SCHEDULER_ROLE_ARN; + + if (!targetArn || !roleArn) { + throw new InternalServerErrorException( + 'Scheduled notifications are not configured on this environment', + ); + } + + await this.scheduler.send( + new CreateScheduleCommand({ + Name: `notification-${randomUUID()}`, + // at() rejects the trailing Z and the milliseconds that toISOString() emits. + ScheduleExpression: `at(${sendAt.toISOString().slice(0, 19)})`, + ScheduleExpressionTimezone: 'UTC', + FlexibleTimeWindow: { Mode: FlexibleTimeWindowMode.OFF }, + ActionAfterCompletion: ActionAfterCompletion.DELETE, + Target: { + Arn: targetArn, + RoleArn: roleArn, + // Scheduler's default retries run up to 185 times over 24 hours. Three + // attempts in ten minutes rides out a blip without hammering the dispatch + // route all day when the configuration is simply wrong. + RetryPolicy: { + MaximumRetryAttempts: 2, + MaximumEventAgeInSeconds: 600, + }, + Input: JSON.stringify({ + subject: content.subject, + body: content.body, + filters: content.filters ?? [], + }), + }, + }), + ); + } + + /** + * Emails every active Volunteer whose foster type is one of `content.filters`, + * or every active Volunteer when no filters are given. + * + * Recipients are Bcc'd in batches of 49 so they can't see each other, with the + * SES sender address in the To slot. A batch that fails to send is logged and + * the remaining batches are still attempted, so one bad address can't stop the + * rest of the send. + * + * @param content - The subject, HTML body, and foster type filters. + * @throws {InternalServerErrorException} If AWS_SES_SENDER_EMAIL is not configured. + */ + async sendNow(content: CreateBulkNotificationDto): Promise { + const sender = process.env.AWS_SES_SENDER_EMAIL; + if (!sender) { + throw new InternalServerErrorException( + 'AWS_SES_SENDER_EMAIL is not configured', + ); + } + + const volunteers = await this.volunteersService.findActiveByFosterTypes( + content.filters ?? [], + ); + // SendEmailDTO validates every Bcc address, so a duplicate (@ArrayUnique) or a + // malformed address (@IsEmail) would fail its whole batch. Drop them up front. + const unique = [ + ...new Set(volunteers.map((v) => v.email.trim().toLowerCase())), + ]; + const recipients = unique.filter( + (email) => isEmail(email) && email.length <= MAX_EMAIL_LENGTH, + ); + if (recipients.length < unique.length) { + this.logger.warn( + `Skipped ${ + unique.length - recipients.length + } Volunteer(s) with an invalid email address`, + ); + } + + if (recipients.length === 0) { + this.logger.warn( + 'No active Volunteers matched the notification filters. Nothing sent.', + ); + return; + } + + let failed = 0; + for (let i = 0; i < recipients.length; i += BCC_BATCH_SIZE) { + const batch = recipients.slice(i, i + BCC_BATCH_SIZE); + + try { + await this.emailsService.sendEmail({ + toEmail: sender, + subject: content.subject, + bodyHtml: content.body, + bccEmails: batch, + }); + } catch (err) { + failed += batch.length; + this.logger.error( + `Bulk notification failed for ${batch.length} recipient(s)`, + err instanceof Error ? err.stack : String(err), + ); + } + } + + // TODO: implement retry mechanism for failed email sends + // failed batches are logged & never retried, caller still gets 202 + if (failed > 0) { + this.logger.warn( + `Bulk notification partially sent: ${failed} of ${recipients.length} recipient(s) not emailed`, + ); + } + } +} From 7a6e64250946ad4d2f88c1cbbb6b41b5bac35380 Mon Sep 17 00:00:00 2001 From: yurikakan Date: Tue, 27 Oct 2026 03:32:46 -0400 Subject: [PATCH 04/11] feat(infra): add notification dispatch lambda and aws setup docs 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. --- example.env | 11 + infra/notification-dispatch-lambda/README.md | 250 +++++++++++++++++++ infra/notification-dispatch-lambda/index.mjs | 38 +++ 3 files changed, 299 insertions(+) create mode 100644 infra/notification-dispatch-lambda/README.md create mode 100644 infra/notification-dispatch-lambda/index.mjs diff --git a/example.env b/example.env index f69209252..9d61e7030 100644 --- a/example.env +++ b/example.env @@ -45,3 +45,14 @@ COGNITO_USER_POOL_ID= COGNITO_CLIENT_ID= # Optional: derived from the user pool ID (_) when left empty. COGNITO_REGION= + +# --- Scheduled notifications (EventBridge Scheduler) --- +# Only needed to schedule a notification for a future time (POST /notifications +# with sendAt). Immediate sends don't touch any of these. +# The ARN of the Lambda in infra/notification-dispatch-lambda that Scheduler invokes: +# 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 +# Lambda's own copy. Generate with: openssl rand -hex 32 +# NOTIFICATIONS_DISPATCH_SECRET= diff --git a/infra/notification-dispatch-lambda/README.md b/infra/notification-dispatch-lambda/README.md new file mode 100644 index 000000000..dc0287b84 --- /dev/null +++ b/infra/notification-dispatch-lambda/README.md @@ -0,0 +1,250 @@ +# Scheduled notifications: AWS setup + +`POST /notifications` with a `sendAt` doesn't send right away. It sets an alarm in AWS, and AWS calls the backend back at that time. + +``` +coordinator volunteers + │ ▲ + │ POST /notifications {sendAt} │ SES (Bcc, 49 per batch) + ▼ │ + backend ──CreateSchedule──> EventBridge Scheduler ──(at sendAt)──> Lambda ──POST /notifications/send-scheduled──> backend + (backend IAM user) (mspca-notif-scheduler-role) (mspca-notif-dispatch) (x-notifications-dispatch-secret) +``` + +- No `sendAt`: the backend sends immediately (`sendNow`) and never touches AWS Scheduler. +- Recipients are looked up **at send time**, not at schedule time. A volunteer who goes inactive in between is left out. +- The schedule deletes itself after it fires (`ActionAfterCompletion.DELETE`). + +Code: [notifications.service.ts](../../apps/backend/src/notifications/notifications.service.ts) · [notifications.controller.ts](../../apps/backend/src/notifications/notifications.controller.ts) · [notifications.dispatch.guard.ts](../../apps/backend/src/notifications/notifications.dispatch.guard.ts) · [index.mjs](./index.mjs) + +--- + +## Resources + +All resources are in `us-east-2`. The **dev** set listed here was created on 2026-10-26. + +| Resource | Name | Purpose | +| --------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| IAM role | `mspca-notif-lambda-role` | Runs the Lambda. It can only write CloudWatch logs (`AWSLambdaBasicExecutionRole`). | +| Lambda | `mspca-notif-dispatch` | Node 22, 30s timeout, 2 async retries. Forwards the schedule's payload to the backend. | +| IAM role | `mspca-notif-scheduler-role` | Lets Scheduler invoke **only** `mspca-notif-dispatch` (inline policy `invoke-notif-dispatch`). | +| IAM user policy | `mspca-notif-create-schedules` | Attached to the backend's IAM user: `scheduler:CreateSchedule` on `schedule/default/notification-*` plus `iam:PassRole` on the scheduler role. | + +## Env vars + +| Where | Var | Value | +| ------- | -------------------------------------- | --------------------------------------------------------------------- | +| Backend | `AWS_NOTIFICATION_DISPATCH_LAMBDA_ARN` | `arn:aws:lambda:us-east-2::function:mspca-notif-dispatch` | +| Backend | `AWS_SCHEDULER_ROLE_ARN` | `arn:aws:iam:::role/mspca-notif-scheduler-role` | +| Backend | `NOTIFICATIONS_DISPATCH_SECRET` | Shared secret. Generate with `openssl rand -hex 32`. | +| Lambda | `NOTIFICATIONS_DISPATCH_SECRET` | **Same** value as the backend's. | +| Lambda | `NOTIFICATIONS_DISPATCH_URL` | `https:///api/notifications/send-scheduled` | + +The backend also needs `SES_ENABLED=true` and a verified `AWS_SES_SENDER_EMAIL`. The SES account already has production access, so recipients don't need verifying. Only the sender does. + +--- + +## Setup (CLI) + +> **zsh users:** always write `${VAR}` with braces. zsh reads `$ACCOUNT_ID:role` as the `:r` modifier and silently produces `...177ole`. + +The CLI identity needs IAM and Lambda admin rights. The backend's SES key is not enough. + +### 0. Shell variables + +These only live in the current terminal tab. Run every step in the same tab. + +```bash +export AWS_REGION=us-east-2 +export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) +export DISPATCH_SECRET=$(openssl rand -hex 32) # save it: step 5 needs it, and re-running this makes a new one +export DISPATCH_URL=https:///api/notifications/send-scheduled +``` + +### 1. Lambda role + +```bash +aws iam create-role --role-name mspca-notif-lambda-role \ + --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}' + +aws iam attach-role-policy --role-name mspca-notif-lambda-role \ + --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole +``` + +Wait about 10s before step 2, until the new role propagates. + +### 2. Lambda + +```bash +zip -j /tmp/notif-dispatch.zip infra/notification-dispatch-lambda/index.mjs + +aws lambda create-function --region ${AWS_REGION} \ + --function-name mspca-notif-dispatch \ + --runtime nodejs22.x --handler index.handler \ + --zip-file fileb:///tmp/notif-dispatch.zip \ + --role arn:aws:iam::${ACCOUNT_ID}:role/mspca-notif-lambda-role \ + --timeout 30 \ + --environment "Variables={NOTIFICATIONS_DISPATCH_URL=${DISPATCH_URL},NOTIFICATIONS_DISPATCH_SECRET=${DISPATCH_SECRET}}" + +aws lambda put-function-event-invoke-config --region ${AWS_REGION} \ + --function-name mspca-notif-dispatch --maximum-retry-attempts 2 +``` + +Use this to change the URL or secret later, for example on a new tunnel or for deploy: + +```bash +aws lambda update-function-configuration --region ${AWS_REGION} --function-name mspca-notif-dispatch \ + --environment "Variables={NOTIFICATIONS_DISPATCH_URL=${DISPATCH_URL},NOTIFICATIONS_DISPATCH_SECRET=${DISPATCH_SECRET}}" +``` + +To redeploy after editing `index.mjs`: rebuild the zip, then run `aws lambda update-function-code --function-name mspca-notif-dispatch --zip-file fileb:///tmp/notif-dispatch.zip`. + +### 3. Scheduler role + +```bash +aws iam create-role --role-name mspca-notif-scheduler-role \ + --assume-role-policy-document "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"scheduler.amazonaws.com\"},\"Action\":\"sts:AssumeRole\",\"Condition\":{\"StringEquals\":{\"aws:SourceAccount\":\"${ACCOUNT_ID}\"}}}]}" + +aws iam put-role-policy --role-name mspca-notif-scheduler-role \ + --policy-name invoke-notif-dispatch \ + --policy-document "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":\"lambda:InvokeFunction\",\"Resource\":\"arn:aws:lambda:${AWS_REGION}:${ACCOUNT_ID}:function:mspca-notif-dispatch\"}]}" +``` + +### 4. Backend IAM user + +```bash +# which IAM user the backend's .env key belongs to +export BACKEND_USER=$( (set -a; source .env; aws sts get-caller-identity --query Arn --output text) | sed 's|.*/||' ) + +aws iam put-user-policy --user-name ${BACKEND_USER} \ + --policy-name mspca-notif-create-schedules \ + --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 + +```bash +echo "AWS_NOTIFICATION_DISPATCH_LAMBDA_ARN=arn:aws:lambda:${AWS_REGION}:${ACCOUNT_ID}:function:mspca-notif-dispatch" +echo "AWS_SCHEDULER_ROLE_ARN=arn:aws:iam::${ACCOUNT_ID}:role/mspca-notif-scheduler-role" +echo "NOTIFICATIONS_DISPATCH_SECRET=${DISPATCH_SECRET}" +``` + +Paste these into `.env` (locally) or the host's config (deployed), then restart the backend. It reads env only at startup. + +--- + +## Local testing + +The Lambda runs in AWS and can't reach `localhost`, so you need a public tunnel to your laptop: + +``` +Lambda / curl ──> https:// ──> tunnel on your Mac ──> localhost:3000 +``` + +| Tunnel | Command | Notes | +| ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------- | +| [ngrok](https://ngrok.com/docs/getting-started/) | `ngrok http 3000` | Free account. One fixed domain, so the URL survives restarts. | +| [cloudflared](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/) | `cloudflared tunnel --url http://localhost:3000` | No account. The URL changes on every restart, so rerun the step 2 env update each time. | + +Run three tabs: A `yarn serve:backend`, B the tunnel, C the commands below. Local auth is off (`AUTH_DISABLED=true`). + +| # | Tests | Command | Expect | +| --- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| T0 | Tunnel reaches the backend | `curl -i -X POST ${DISPATCH_URL}` | 401 (no secret sent) | +| T1 | Secret matches | `curl -i -X POST ${DISPATCH_URL} -H "x-notifications-dispatch-secret: ${DISPATCH_SECRET}" -H 'content-type: application/json' -d '{}'` | 400 (guard passed, DTO failed) | +| T2 | Wrong secret is rejected | same as T1 with `-H 'x-notifications-dispatch-secret: wrong'` and a valid body | 401, no email | +| T3 | Send now with a filter | `curl -i -X POST localhost:3000/api/notifications -H 'content-type: application/json' -d '{"subject":"t3","body":"

t3

","filters":["Dog"]}'` | 202. Only Dog volunteers get it. | +| T4 | Lambda to backend | `aws lambda invoke --region ${AWS_REGION} --function-name mspca-notif-dispatch --cli-binary-format raw-in-base64-out --payload '{"subject":"t4","body":"

t4

","filters":["Cat"]}' /tmp/out.json` | `StatusCode 200`, `null` in out.json, the tunnel logs `204` | +| T5 | Full chain | `curl -i -X POST localhost:3000/api/notifications -H 'content-type: application/json' -d "{\"subject\":\"t5\",\"body\":\"

t5

\",\"filters\":[],\"sendAt\":\"$(date -u -v+2M +%Y-%m-%dT%H:%M:%SZ)\"}"` | 202. About 2 min later every Active volunteer gets it. | +| T6 | Lambda retries | Stop the backend, then run T4 with `--invocation-type Event`. Check `aws logs tail /aws/lambda/mspca-notif-dispatch --region ${AWS_REGION} --since 10m`. | 3 `Dispatch failed` lines (1 try + 2 retries), no email | +| T7 | Validation | `sendAt` in the past, or `filters:["Horse"]` | 400 | + +`date -u -v+2M` is macOS syntax. On Linux use `date -u -d '+2 min' ...`. + +### Dev run, 2026-10-26 + +| Test | Result | +| ----------------------------------------------------------- | ---------------------------------------------------------- | +| Unit tests (`npx jest apps/backend/src/notifications`) | ✅ 4 suites, 43 tests (2026-10-27) | +| T0 no secret / T2 wrong secret | ✅ 401 / 401 | +| T1 right secret, empty body | ✅ 400 | +| T3 send now, `filters: ["Dog"]` | ✅ 202, no send errors logged | +| T4 Lambda to backend, `filters: ["Cat"]` | ✅ `StatusCode 200`, `null` | +| T5 full chain, `filters: []` | ✅ All 4 seeded Active volunteers received it | +| T7 past `sendAt` / bad filter / missing body / CRLF subject | ✅ 400 ×4 | +| T6 Lambda retries, backend down | ✅ 3 attempts (07:24, 07:25, 07:27 UTC), all 502, no email | + +Seeded test rows: `foster_volunteers` ids 82–85, one per foster type. Remove them with `DELETE FROM foster_volunteers WHERE volunteer_id BETWEEN 82 AND 85;`. + +--- + +## Prod checklist + +**Blockers** + +- [ ] **Make `/send-scheduled` skip Cognito.** `CognitoJWTGuard` is a global `APP_GUARD` ([cognito.module.ts](../../apps/backend/src/aws/cognito/cognito.module.ts)), and the route has no `@Public()`. The Lambda has no Cognito token, so once auth is on every dispatch gets a 401. It works locally only because `AUTH_DISABLED=true`. Fix: add `@Public()` to the route; `NotificationsDispatchGuard` still protects it. +- [ ] **Deploy the backend** at a public HTTPS host. The repo has no deploy pipeline yet ([backend_build.yml](../../.github/workflows/backend_build.yml) only builds). +- [ ] **Point the Lambda at prod:** set `NOTIFICATIONS_DISPATCH_URL=https:///api/notifications/send-scheduled` (step 2 update). +- [ ] **Put the backend env vars in the host's config**, never a committed file. Set the 3 vars from step 5 plus `SES_ENABLED=true` and `AWS_SES_SENDER_EMAIL`. + +**Should do** + +- [ ] **Separate prod resources** (`mspca-notif-dispatch-prod`, its own scheduler role, its own secret), so dev testing can never email real volunteers. Rerun steps 0–5 with new names. +- [ ] **Least-privilege backend user.** Prod should use a dedicated IAM user with only SES send plus the step 4 policy, not a personal or admin key. +- [ ] **Send from a verified domain, not Gmail.** The dev Gmail sender goes out "via amazonses.com", and Gmail put a test email in spam. Use an address on a verified domain with DKIM (for example `c4cneu.com`, already verified in SES). +- [ ] **Rotate the dev secret** if it was ever shared or pasted anywhere. Change it on both sides together. +- [ ] **Delete the dev resources** when they're no longer needed (see Cleanup). + +**Known gaps (follow-up tickets)** + +- [ ] **Failed sends aren't retried.** `sendNow` logs failed SES batches and returns normally (`TODO` in [notifications.service.ts](../../apps/backend/src/notifications/notifications.service.ts)), so `/send-scheduled` returns 204 and the Lambda never retries. Lambda retries cover only "backend unreachable or non-2xx". +- [ ] **One bad stored email drops its whole batch of 49.** `EmailsService` validates every Bcc address, and the DB has no email format check. Validate on write, as SSF does. +- [ ] **Retries can double-send.** A retry after a partial send repeats the earlier batches (at-least-once delivery). +- [ ] **Role gating.** Any authenticated user can call `POST /notifications`. It should be coordinators only (see the `TODO` in the controller). +- [ ] **Swap to `ArrayOverlap` when [PR #11](https://github.com/Code-4-Community/mspca/pull/11) merges.** `fosterType` becomes `FosterType[]`, and `In()` on an array column is a SQL error. +- [ ] **Email dedupe is case-sensitive** (`dup@` and `DUP@` both get Bcc'd). + +**Retry budget, end to end** + +| Layer | Retries | Configured in | +| ------------------- | ---------------- | ------------------------------------------- | +| Scheduler to Lambda | 2, within 10 min | `RetryPolicy` in `notifications.service.ts` | +| Lambda to backend | 2 (async) | step 2, `put-function-event-invoke-config` | +| Backend to SES | none | known gap above | + +If every retry fails, the failure is only logged in CloudWatch. There is no dead-letter queue. + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +| ----------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `...:iam::489...177ole/...` regex error | zsh `:r` modifier | Use `${ACCOUNT_ID}` with braces | +| `POST /notifications` with `sendAt` returns 500 | Scheduler env vars unset, or `AccessDenied` in the backend log | Do step 5 and restart; check that the step 4 `iam:PassRole` exists | +| Lambda: `Dispatch failed: 401` | Secrets differ (or, in prod, the Cognito guard) | Use the same secret on both sides; see the `@Public()` blocker | +| Lambda: `Dispatch failed: 404` | Wrong URL path | It must end in `/api/notifications/send-scheduled` | +| Lambda: fetch failed / timeout | Tunnel or backend is down | Restart them; check the URL is still current | +| No Lambda logs at all | Scheduler couldn't invoke it | Check the step 3 role and the Lambda ARN in `.env` | +| `list-schedules`: AccessDenied | Your CLI user lacks Scheduler read access (not needed by the app) | Add `scheduler:ListSchedules` and `scheduler:GetSchedule` to your own user | +| `makeBuilder is not a function` on boot | Stale Nx project graph | Run `nx reset` | + +## Cleanup + +```bash +aws lambda delete-function --region ${AWS_REGION} --function-name mspca-notif-dispatch +aws iam detach-role-policy --role-name mspca-notif-lambda-role --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole +aws iam delete-role --role-name mspca-notif-lambda-role +aws iam delete-role-policy --role-name mspca-notif-scheduler-role --policy-name invoke-notif-dispatch +aws iam delete-role --role-name mspca-notif-scheduler-role +aws iam delete-user-policy --user-name ${BACKEND_USER} --policy-name mspca-notif-create-schedules +``` + +## Refs + +- [EventBridge Scheduler: one-time `at()` schedules](https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html#one-time) +- [Scheduler execution role](https://docs.aws.amazon.com/scheduler/latest/UserGuide/setting-up.html#setting-up-execution-role) +- [Scheduler retry policy](https://docs.aws.amazon.com/scheduler/latest/UserGuide/managing-schedule-retries.html) +- [Lambda async invocation and retries](https://docs.aws.amazon.com/lambda/latest/dg/invocation-async.html) +- [`iam:PassRole`](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_passrole.html) +- [SES sandbox vs production access](https://docs.aws.amazon.com/ses/latest/dg/request-production-access.html) diff --git a/infra/notification-dispatch-lambda/index.mjs b/infra/notification-dispatch-lambda/index.mjs new file mode 100644 index 000000000..ee84cc378 --- /dev/null +++ b/infra/notification-dispatch-lambda/index.mjs @@ -0,0 +1,38 @@ +/** + * EventBridge Scheduler target for scheduled bulk notifications. + * + * Carries no business logic. Scheduler passes the notification stored in the + * schedule's Input as this handler's event, and the handler forwards it to the + * backend, which resolves the recipients at send time and sends the email. + * + * Environment: + * NOTIFICATIONS_DISPATCH_URL https:///api/notifications/send-scheduled + * NOTIFICATIONS_DISPATCH_SECRET must match the backend's value + */ +export const handler = async (event) => { + const url = process.env.NOTIFICATIONS_DISPATCH_URL; + const secret = process.env.NOTIFICATIONS_DISPATCH_SECRET; + + if (!url || !secret) { + throw new Error( + 'NOTIFICATIONS_DISPATCH_URL and NOTIFICATIONS_DISPATCH_SECRET must both be set', + ); + } + + const response = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-notifications-dispatch-secret': secret, + }, + body: JSON.stringify(event), + }); + + // Scheduler invokes this Lambda asynchronously, so throwing triggers Lambda's async + // 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()}`, + ); + } +}; From 23e608fdfdf302861fb43aa04b05e0df0915c0f2 Mon Sep 17 00:00:00 2001 From: yurikakan Date: Tue, 27 Oct 2026 03:47:42 -0400 Subject: [PATCH 05/11] docs(infra): correct known gaps for invalid and duplicate emails sendNow already drops malformed addresses and dedupes case-insensitively. --- infra/notification-dispatch-lambda/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/infra/notification-dispatch-lambda/README.md b/infra/notification-dispatch-lambda/README.md index dc0287b84..c176900b8 100644 --- a/infra/notification-dispatch-lambda/README.md +++ b/infra/notification-dispatch-lambda/README.md @@ -198,11 +198,10 @@ Seeded test rows: `foster_volunteers` ids 82–85, one per foster type. Remove t **Known gaps (follow-up tickets)** - [ ] **Failed sends aren't retried.** `sendNow` logs failed SES batches and returns normally (`TODO` in [notifications.service.ts](../../apps/backend/src/notifications/notifications.service.ts)), so `/send-scheduled` returns 204 and the Lambda never retries. Lambda retries cover only "backend unreachable or non-2xx". -- [ ] **One bad stored email drops its whole batch of 49.** `EmailsService` validates every Bcc address, and the DB has no email format check. Validate on write, as SSF does. +- [ ] **Invalid stored emails are skipped silently.** `sendNow` drops malformed addresses before sending and only logs a count, because the DB has no email format check. Validate on write, as SSF does. - [ ] **Retries can double-send.** A retry after a partial send repeats the earlier batches (at-least-once delivery). - [ ] **Role gating.** Any authenticated user can call `POST /notifications`. It should be coordinators only (see the `TODO` in the controller). - [ ] **Swap to `ArrayOverlap` when [PR #11](https://github.com/Code-4-Community/mspca/pull/11) merges.** `fosterType` becomes `FosterType[]`, and `In()` on an array column is a SQL error. -- [ ] **Email dedupe is case-sensitive** (`dup@` and `DUP@` both get Bcc'd). **Retry budget, end to end** From 46f3ff5e36608f68f5bdb839aae9683fb5633e63 Mon Sep 17 00:00:00 2001 From: yurikakan Date: Tue, 27 Oct 2026 03:58:06 -0400 Subject: [PATCH 06/11] test(notifications): stub jwks-rsa in module spec coordinators module now imports cognito, so the module spec pulls in jwks-rsa and its esm jose dependency, which jest cannot parse. --- apps/backend/src/notifications/notifications.module.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/backend/src/notifications/notifications.module.spec.ts b/apps/backend/src/notifications/notifications.module.spec.ts index 238ab7f6d..8a9644e67 100644 --- a/apps/backend/src/notifications/notifications.module.spec.ts +++ b/apps/backend/src/notifications/notifications.module.spec.ts @@ -1,5 +1,9 @@ import { NotificationsModule } from './notifications.module'; +// VolunteersModule reaches CognitoModule, whose guard imports jwks-rsa. Its ESM +// `jose` dependency cannot be parsed by Jest, so it is stubbed out here. +jest.mock('jwks-rsa', () => ({ __esModule: true, default: jest.fn() })); + describe('NotificationsModule', () => { const REQUIRED_FOR_SCHEDULING = [ 'AWS_REGION', From 2f8751302e41d112a9f8a27f3e7fa27bfbb2e2e2 Mon Sep 17 00:00:00 2001 From: yurikakan Date: Tue, 27 Oct 2026 04:20:35 -0400 Subject: [PATCH 07/11] docs(infra): lead readme with using the existing dev setup 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. --- infra/notification-dispatch-lambda/README.md | 121 ++++++++++++------- 1 file changed, 75 insertions(+), 46 deletions(-) diff --git a/infra/notification-dispatch-lambda/README.md b/infra/notification-dispatch-lambda/README.md index c176900b8..cc07a7a63 100644 --- a/infra/notification-dispatch-lambda/README.md +++ b/infra/notification-dispatch-lambda/README.md @@ -44,7 +44,81 @@ The backend also needs `SES_ENABLED=true` and a verified `AWS_SES_SENDER_EMAIL`. --- -## Setup (CLI) +## Use the existing dev setup + +The dev resources above already exist, so you don't create anything. You only connect to them. + +- **Immediate sends** (no `sendAt`) need no Scheduler setup. The usual SES vars are enough. +- **Scheduled sends** need these 3 vars in your `.env`. Ask the ticket owner for the values over DM, and never commit them: + ``` + AWS_NOTIFICATION_DISPATCH_LAMBDA_ARN= + AWS_SCHEDULER_ROLE_ARN= + NOTIFICATIONS_DISPATCH_SECRET= + ``` +- Your `.env` AWS key's IAM user needs the `mspca-notif-create-schedules` policy. If you use the same dev key the setup was tested with, it already has it. Otherwise run [step 4](#4-backend-iam-user). + +> **There is one Lambda, and it calls one URL.** Right now that URL is whoever last pointed it at their tunnel. To test scheduled sends end to end from your laptop, point the Lambda at your own tunnel, and tell the team, because this breaks it for whoever had it before: +> +> ```bash +> export AWS_REGION=us-east-2 +> export DISPATCH_URL=https:///api/notifications/send-scheduled +> export DISPATCH_SECRET= +> aws lambda update-function-configuration --region ${AWS_REGION} --function-name mspca-notif-dispatch \ +> --environment "Variables={NOTIFICATIONS_DISPATCH_URL=${DISPATCH_URL},NOTIFICATIONS_DISPATCH_SECRET=${DISPATCH_SECRET}}" +> ``` +> +> This goes away once the backend is deployed and the Lambda points there for good. + +--- + +## Local testing + +The Lambda runs in AWS and can't reach `localhost`, so you need a public tunnel to your laptop: + +``` +Lambda / curl ──> https:// ──> tunnel on your Mac ──> localhost:3000 +``` + +| Tunnel | Command | Notes | +| ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------- | +| [ngrok](https://ngrok.com/docs/getting-started/) | `ngrok http 3000` | Free account. One fixed domain, so the URL survives restarts. | +| [cloudflared](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/) | `cloudflared tunnel --url http://localhost:3000` | No account. The URL changes on every restart, so repoint the Lambda each time (see the box above). | + +Run three tabs: A `yarn serve:backend`, B the tunnel, C the commands below. Local auth is off (`AUTH_DISABLED=true`). Tab C needs the `AWS_REGION`, `DISPATCH_URL` and `DISPATCH_SECRET` shell variables from the box above (or from [step 0](#0-shell-variables)). + +| # | Tests | Command | Expect | +| --- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| T0 | Tunnel reaches the backend | `curl -i -X POST ${DISPATCH_URL}` | 401 (no secret sent) | +| T1 | Secret matches | `curl -i -X POST ${DISPATCH_URL} -H "x-notifications-dispatch-secret: ${DISPATCH_SECRET}" -H 'content-type: application/json' -d '{}'` | 400 (guard passed, DTO failed) | +| T2 | Wrong secret is rejected | same as T1 with `-H 'x-notifications-dispatch-secret: wrong'` and a valid body | 401, no email | +| T3 | Send now with a filter | `curl -i -X POST localhost:3000/api/notifications -H 'content-type: application/json' -d '{"subject":"t3","body":"

t3

","filters":["Dog"]}'` | 202. Only Dog volunteers get it. | +| T4 | Lambda to backend | `aws lambda invoke --region ${AWS_REGION} --function-name mspca-notif-dispatch --cli-binary-format raw-in-base64-out --payload '{"subject":"t4","body":"

t4

","filters":["Cat"]}' /tmp/out.json` | `StatusCode 200`, `null` in out.json, the tunnel logs `204` | +| T5 | Full chain | `curl -i -X POST localhost:3000/api/notifications -H 'content-type: application/json' -d "{\"subject\":\"t5\",\"body\":\"

t5

\",\"filters\":[],\"sendAt\":\"$(date -u -v+2M +%Y-%m-%dT%H:%M:%SZ)\"}"` | 202. About 2 min later every Active volunteer gets it. | +| T6 | Lambda retries | Stop the backend, then run T4 with `--invocation-type Event`. Check `aws logs tail /aws/lambda/mspca-notif-dispatch --region ${AWS_REGION} --since 10m`. | 3 `Dispatch failed` lines (1 try + 2 retries), no email | +| T7 | Validation | `sendAt` in the past, or `filters:["Horse"]` | 400 | + +`date -u -v+2M` is macOS syntax. On Linux use `date -u -d '+2 min' ...`. + +### Dev run, 2026-10-26 + +| Test | Result | +| ----------------------------------------------------------- | ---------------------------------------------------------- | +| Unit tests (`npx jest apps/backend/src/notifications`) | ✅ 4 suites, 43 tests (2026-10-27) | +| T0 no secret / T2 wrong secret | ✅ 401 / 401 | +| T1 right secret, empty body | ✅ 400 | +| T3 send now, `filters: ["Dog"]` | ✅ 202, no send errors logged | +| T4 Lambda to backend, `filters: ["Cat"]` | ✅ `StatusCode 200`, `null` | +| T5 full chain, `filters: []` | ✅ All 4 seeded Active volunteers received it | +| T7 past `sendAt` / bad filter / missing body / CRLF subject | ✅ 400 ×4 | +| T6 Lambda retries, backend down | ✅ 3 attempts (07:24, 07:25, 07:27 UTC), all 502, no email | + +Seeded test rows: `foster_volunteers` ids 82–85, one per foster type. Remove them with `DELETE FROM foster_volunteers WHERE volunteer_id BETWEEN 82 AND 85;`. + +--- + +## Create from scratch (prod / rebuild) + +Only needed for a new environment (prod, staging, another AWS account), or if the dev resources are deleted. For prod, use new names (for example `mspca-notif-dispatch-prod`) and a new secret. > **zsh users:** always write `${VAR}` with braces. zsh reads `$ACCOUNT_ID:role` as the `:r` modifier and silently produces `...177ole`. @@ -133,51 +207,6 @@ Paste these into `.env` (locally) or the host's config (deployed), then restart --- -## Local testing - -The Lambda runs in AWS and can't reach `localhost`, so you need a public tunnel to your laptop: - -``` -Lambda / curl ──> https:// ──> tunnel on your Mac ──> localhost:3000 -``` - -| Tunnel | Command | Notes | -| ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------- | -| [ngrok](https://ngrok.com/docs/getting-started/) | `ngrok http 3000` | Free account. One fixed domain, so the URL survives restarts. | -| [cloudflared](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/) | `cloudflared tunnel --url http://localhost:3000` | No account. The URL changes on every restart, so rerun the step 2 env update each time. | - -Run three tabs: A `yarn serve:backend`, B the tunnel, C the commands below. Local auth is off (`AUTH_DISABLED=true`). - -| # | Tests | Command | Expect | -| --- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -| T0 | Tunnel reaches the backend | `curl -i -X POST ${DISPATCH_URL}` | 401 (no secret sent) | -| T1 | Secret matches | `curl -i -X POST ${DISPATCH_URL} -H "x-notifications-dispatch-secret: ${DISPATCH_SECRET}" -H 'content-type: application/json' -d '{}'` | 400 (guard passed, DTO failed) | -| T2 | Wrong secret is rejected | same as T1 with `-H 'x-notifications-dispatch-secret: wrong'` and a valid body | 401, no email | -| T3 | Send now with a filter | `curl -i -X POST localhost:3000/api/notifications -H 'content-type: application/json' -d '{"subject":"t3","body":"

t3

","filters":["Dog"]}'` | 202. Only Dog volunteers get it. | -| T4 | Lambda to backend | `aws lambda invoke --region ${AWS_REGION} --function-name mspca-notif-dispatch --cli-binary-format raw-in-base64-out --payload '{"subject":"t4","body":"

t4

","filters":["Cat"]}' /tmp/out.json` | `StatusCode 200`, `null` in out.json, the tunnel logs `204` | -| T5 | Full chain | `curl -i -X POST localhost:3000/api/notifications -H 'content-type: application/json' -d "{\"subject\":\"t5\",\"body\":\"

t5

\",\"filters\":[],\"sendAt\":\"$(date -u -v+2M +%Y-%m-%dT%H:%M:%SZ)\"}"` | 202. About 2 min later every Active volunteer gets it. | -| T6 | Lambda retries | Stop the backend, then run T4 with `--invocation-type Event`. Check `aws logs tail /aws/lambda/mspca-notif-dispatch --region ${AWS_REGION} --since 10m`. | 3 `Dispatch failed` lines (1 try + 2 retries), no email | -| T7 | Validation | `sendAt` in the past, or `filters:["Horse"]` | 400 | - -`date -u -v+2M` is macOS syntax. On Linux use `date -u -d '+2 min' ...`. - -### Dev run, 2026-10-26 - -| Test | Result | -| ----------------------------------------------------------- | ---------------------------------------------------------- | -| Unit tests (`npx jest apps/backend/src/notifications`) | ✅ 4 suites, 43 tests (2026-10-27) | -| T0 no secret / T2 wrong secret | ✅ 401 / 401 | -| T1 right secret, empty body | ✅ 400 | -| T3 send now, `filters: ["Dog"]` | ✅ 202, no send errors logged | -| T4 Lambda to backend, `filters: ["Cat"]` | ✅ `StatusCode 200`, `null` | -| T5 full chain, `filters: []` | ✅ All 4 seeded Active volunteers received it | -| T7 past `sendAt` / bad filter / missing body / CRLF subject | ✅ 400 ×4 | -| T6 Lambda retries, backend down | ✅ 3 attempts (07:24, 07:25, 07:27 UTC), all 502, no email | - -Seeded test rows: `foster_volunteers` ids 82–85, one per foster type. Remove them with `DELETE FROM foster_volunteers WHERE volunteer_id BETWEEN 82 AND 85;`. - ---- - ## Prod checklist **Blockers** From dc3227b2cabff2886f1ff162b21b2affd2cc0a22 Mon Sep 17 00:00:00 2001 From: yurikakan Date: Tue, 27 Oct 2026 04:21:48 -0400 Subject: [PATCH 08/11] docs(infra): drop tunnel options, dev lambda points at owner's tunnel --- infra/notification-dispatch-lambda/README.md | 23 ++++---------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/infra/notification-dispatch-lambda/README.md b/infra/notification-dispatch-lambda/README.md index cc07a7a63..fa2a6804b 100644 --- a/infra/notification-dispatch-lambda/README.md +++ b/infra/notification-dispatch-lambda/README.md @@ -57,34 +57,19 @@ The dev resources above already exist, so you don't create anything. You only co ``` - Your `.env` AWS key's IAM user needs the `mspca-notif-create-schedules` policy. If you use the same dev key the setup was tested with, it already has it. Otherwise run [step 4](#4-backend-iam-user). -> **There is one Lambda, and it calls one URL.** Right now that URL is whoever last pointed it at their tunnel. To test scheduled sends end to end from your laptop, point the Lambda at your own tunnel, and tell the team, because this breaks it for whoever had it before: -> -> ```bash -> export AWS_REGION=us-east-2 -> export DISPATCH_URL=https:///api/notifications/send-scheduled -> export DISPATCH_SECRET= -> aws lambda update-function-configuration --region ${AWS_REGION} --function-name mspca-notif-dispatch \ -> --environment "Variables={NOTIFICATIONS_DISPATCH_URL=${DISPATCH_URL},NOTIFICATIONS_DISPATCH_SECRET=${DISPATCH_SECRET}}" -> ``` -> -> This goes away once the backend is deployed and the Lambda points there for good. +> **The dev Lambda calls the ticket owner's ngrok tunnel.** Any scheduled send (and T4–T6 below), from anyone's laptop, fires into the owner's local backend, not yours. Test immediate sends and `/send-scheduled` directly on your own `localhost`, and ask the owner to run the full chain. This goes away once the backend is deployed and the Lambda points there. --- ## Local testing -The Lambda runs in AWS and can't reach `localhost`, so you need a public tunnel to your laptop: +The Lambda runs in AWS and can't reach `localhost`, so the owner runs `ngrok http 3000` and the Lambda's URL points at that tunnel: ``` -Lambda / curl ──> https:// ──> tunnel on your Mac ──> localhost:3000 +Lambda ──> https:// ──> owner's localhost:3000 ``` -| Tunnel | Command | Notes | -| ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------- | -| [ngrok](https://ngrok.com/docs/getting-started/) | `ngrok http 3000` | Free account. One fixed domain, so the URL survives restarts. | -| [cloudflared](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/) | `cloudflared tunnel --url http://localhost:3000` | No account. The URL changes on every restart, so repoint the Lambda each time (see the box above). | - -Run three tabs: A `yarn serve:backend`, B the tunnel, C the commands below. Local auth is off (`AUTH_DISABLED=true`). Tab C needs the `AWS_REGION`, `DISPATCH_URL` and `DISPATCH_SECRET` shell variables from the box above (or from [step 0](#0-shell-variables)). +Run `yarn serve:backend` with `AUTH_DISABLED=true`, then in another tab set `export AWS_REGION=us-east-2 DISPATCH_SECRET=` and `DISPATCH_URL`. Use the ngrok URL (owner) or `http://localhost:3000/api/notifications/send-scheduled` (everyone else). T4–T6 only work on the owner's machine. | # | Tests | Command | Expect | | --- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | From 23563705c742766d445d7033bd54c0ba9401f065 Mon Sep 17 00:00:00 2001 From: yurikakan Date: Tue, 27 Oct 2026 04:23:20 -0400 Subject: [PATCH 09/11] docs(infra): cut readme down to the essentials merge test results into the test table, collapse setup into one script block, trim checklist, troubleshooting and refs. --- infra/notification-dispatch-lambda/README.md | 255 +++++-------------- 1 file changed, 61 insertions(+), 194 deletions(-) diff --git a/infra/notification-dispatch-lambda/README.md b/infra/notification-dispatch-lambda/README.md index fa2a6804b..c4605c43a 100644 --- a/infra/notification-dispatch-lambda/README.md +++ b/infra/notification-dispatch-lambda/README.md @@ -1,246 +1,120 @@ # Scheduled notifications: AWS setup -`POST /notifications` with a `sendAt` doesn't send right away. It sets an alarm in AWS, and AWS calls the backend back at that time. +`POST /notifications` with `sendAt` doesn't send right away. It creates a one-time AWS schedule, and AWS calls the backend back at that time. ``` -coordinator volunteers - │ ▲ - │ POST /notifications {sendAt} │ SES (Bcc, 49 per batch) - ▼ │ - backend ──CreateSchedule──> EventBridge Scheduler ──(at sendAt)──> Lambda ──POST /notifications/send-scheduled──> backend - (backend IAM user) (mspca-notif-scheduler-role) (mspca-notif-dispatch) (x-notifications-dispatch-secret) +immediate: POST /notifications --> sendNow() --> SES +scheduled: POST /notifications --> Scheduler (waits until sendAt) --> Lambda --> POST /notifications/send-scheduled --> sendNow() --> SES ``` -- No `sendAt`: the backend sends immediately (`sendNow`) and never touches AWS Scheduler. -- Recipients are looked up **at send time**, not at schedule time. A volunteer who goes inactive in between is left out. -- The schedule deletes itself after it fires (`ActionAfterCompletion.DELETE`). +- Recipients are looked up at send time, so volunteers who went inactive in between are left out. +- The schedule deletes itself after it fires. +- Code: [notifications/](../../apps/backend/src/notifications) · [index.mjs](./index.mjs) -Code: [notifications.service.ts](../../apps/backend/src/notifications/notifications.service.ts) · [notifications.controller.ts](../../apps/backend/src/notifications/notifications.controller.ts) · [notifications.dispatch.guard.ts](../../apps/backend/src/notifications/notifications.dispatch.guard.ts) · [index.mjs](./index.mjs) +## Resources (dev, `us-east-2`) ---- +| Resource | Name | Purpose | +| ----------- | ------------------------------ | ---------------------------------------------------------------------------------- | +| Lambda | `mspca-notif-dispatch` | Node 22, 30s timeout, 2 async retries. Forwards the schedule to `/send-scheduled`. | +| IAM role | `mspca-notif-lambda-role` | Runs the Lambda (CloudWatch logs only) | +| IAM role | `mspca-notif-scheduler-role` | Lets Scheduler invoke only this Lambda | +| User policy | `mspca-notif-create-schedules` | Lets the backend's IAM user create schedules and pass the scheduler role | -## Resources +## Connect to the existing dev setup -All resources are in `us-east-2`. The **dev** set listed here was created on 2026-10-26. - -| Resource | Name | Purpose | -| --------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| IAM role | `mspca-notif-lambda-role` | Runs the Lambda. It can only write CloudWatch logs (`AWSLambdaBasicExecutionRole`). | -| Lambda | `mspca-notif-dispatch` | Node 22, 30s timeout, 2 async retries. Forwards the schedule's payload to the backend. | -| IAM role | `mspca-notif-scheduler-role` | Lets Scheduler invoke **only** `mspca-notif-dispatch` (inline policy `invoke-notif-dispatch`). | -| IAM user policy | `mspca-notif-create-schedules` | Attached to the backend's IAM user: `scheduler:CreateSchedule` on `schedule/default/notification-*` plus `iam:PassRole` on the scheduler role. | - -## Env vars - -| Where | Var | Value | -| ------- | -------------------------------------- | --------------------------------------------------------------------- | -| Backend | `AWS_NOTIFICATION_DISPATCH_LAMBDA_ARN` | `arn:aws:lambda:us-east-2::function:mspca-notif-dispatch` | -| Backend | `AWS_SCHEDULER_ROLE_ARN` | `arn:aws:iam:::role/mspca-notif-scheduler-role` | -| Backend | `NOTIFICATIONS_DISPATCH_SECRET` | Shared secret. Generate with `openssl rand -hex 32`. | -| Lambda | `NOTIFICATIONS_DISPATCH_SECRET` | **Same** value as the backend's. | -| Lambda | `NOTIFICATIONS_DISPATCH_URL` | `https:///api/notifications/send-scheduled` | - -The backend also needs `SES_ENABLED=true` and a verified `AWS_SES_SENDER_EMAIL`. The SES account already has production access, so recipients don't need verifying. Only the sender does. - ---- - -## Use the existing dev setup - -The dev resources above already exist, so you don't create anything. You only connect to them. - -- **Immediate sends** (no `sendAt`) need no Scheduler setup. The usual SES vars are enough. -- **Scheduled sends** need these 3 vars in your `.env`. Ask the ticket owner for the values over DM, and never commit them: - ``` - AWS_NOTIFICATION_DISPATCH_LAMBDA_ARN= - AWS_SCHEDULER_ROLE_ARN= - NOTIFICATIONS_DISPATCH_SECRET= - ``` -- Your `.env` AWS key's IAM user needs the `mspca-notif-create-schedules` policy. If you use the same dev key the setup was tested with, it already has it. Otherwise run [step 4](#4-backend-iam-user). - -> **The dev Lambda calls the ticket owner's ngrok tunnel.** Any scheduled send (and T4–T6 below), from anyone's laptop, fires into the owner's local backend, not yours. Test immediate sends and `/send-scheduled` directly on your own `localhost`, and ask the owner to run the full chain. This goes away once the backend is deployed and the Lambda points there. - ---- - -## Local testing - -The Lambda runs in AWS and can't reach `localhost`, so the owner runs `ngrok http 3000` and the Lambda's URL points at that tunnel: +Nothing to create. Immediate sends need only the usual SES vars. Scheduled sends also need these 3 in `.env`. Ask the ticket owner by DM, and never commit them: ``` -Lambda ──> https:// ──> owner's localhost:3000 +AWS_NOTIFICATION_DISPATCH_LAMBDA_ARN= +AWS_SCHEDULER_ROLE_ARN= +NOTIFICATIONS_DISPATCH_SECRET= # same value as the Lambda's ``` -Run `yarn serve:backend` with `AUTH_DISABLED=true`, then in another tab set `export AWS_REGION=us-east-2 DISPATCH_SECRET=` and `DISPATCH_URL`. Use the ngrok URL (owner) or `http://localhost:3000/api/notifications/send-scheduled` (everyone else). T4–T6 only work on the owner's machine. - -| # | Tests | Command | Expect | -| --- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -| T0 | Tunnel reaches the backend | `curl -i -X POST ${DISPATCH_URL}` | 401 (no secret sent) | -| T1 | Secret matches | `curl -i -X POST ${DISPATCH_URL} -H "x-notifications-dispatch-secret: ${DISPATCH_SECRET}" -H 'content-type: application/json' -d '{}'` | 400 (guard passed, DTO failed) | -| T2 | Wrong secret is rejected | same as T1 with `-H 'x-notifications-dispatch-secret: wrong'` and a valid body | 401, no email | -| T3 | Send now with a filter | `curl -i -X POST localhost:3000/api/notifications -H 'content-type: application/json' -d '{"subject":"t3","body":"

t3

","filters":["Dog"]}'` | 202. Only Dog volunteers get it. | -| T4 | Lambda to backend | `aws lambda invoke --region ${AWS_REGION} --function-name mspca-notif-dispatch --cli-binary-format raw-in-base64-out --payload '{"subject":"t4","body":"

t4

","filters":["Cat"]}' /tmp/out.json` | `StatusCode 200`, `null` in out.json, the tunnel logs `204` | -| T5 | Full chain | `curl -i -X POST localhost:3000/api/notifications -H 'content-type: application/json' -d "{\"subject\":\"t5\",\"body\":\"

t5

\",\"filters\":[],\"sendAt\":\"$(date -u -v+2M +%Y-%m-%dT%H:%M:%SZ)\"}"` | 202. About 2 min later every Active volunteer gets it. | -| T6 | Lambda retries | Stop the backend, then run T4 with `--invocation-type Event`. Check `aws logs tail /aws/lambda/mspca-notif-dispatch --region ${AWS_REGION} --since 10m`. | 3 `Dispatch failed` lines (1 try + 2 retries), no email | -| T7 | Validation | `sendAt` in the past, or `filters:["Horse"]` | 400 | - -`date -u -v+2M` is macOS syntax. On Linux use `date -u -d '+2 min' ...`. +> The dev Lambda calls the **owner's ngrok tunnel** (`ngrok http 3000`). Any scheduled send, from anyone's laptop, fires into the owner's local backend. Test the full chain on the owner's machine. This goes away once the backend is deployed. -### Dev run, 2026-10-26 +## Testing -| Test | Result | -| ----------------------------------------------------------- | ---------------------------------------------------------- | -| Unit tests (`npx jest apps/backend/src/notifications`) | ✅ 4 suites, 43 tests (2026-10-27) | -| T0 no secret / T2 wrong secret | ✅ 401 / 401 | -| T1 right secret, empty body | ✅ 400 | -| T3 send now, `filters: ["Dog"]` | ✅ 202, no send errors logged | -| T4 Lambda to backend, `filters: ["Cat"]` | ✅ `StatusCode 200`, `null` | -| T5 full chain, `filters: []` | ✅ All 4 seeded Active volunteers received it | -| T7 past `sendAt` / bad filter / missing body / CRLF subject | ✅ 400 ×4 | -| T6 Lambda retries, backend down | ✅ 3 attempts (07:24, 07:25, 07:27 UTC), all 502, no email | +Run `yarn serve:backend` with `AUTH_DISABLED=true`. In another tab, set `AWS_REGION=us-east-2`, `DISPATCH_SECRET` (your `.env` value) and `DISPATCH_URL`: the ngrok URL on the owner's machine, or `http://localhost:3000/api/notifications/send-scheduled` for everyone else. -Seeded test rows: `foster_volunteers` ids 82–85, one per foster type. Remove them with `DELETE FROM foster_volunteers WHERE volunteer_id BETWEEN 82 AND 85;`. +| # | Test | Command | Expect | Dev run | +| --- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | ------- | +| T0 | No secret | `curl -i -X POST ${DISPATCH_URL}` | 401 | ✅ | +| T1 | Right secret, empty body | T0 + `-H "x-notifications-dispatch-secret: ${DISPATCH_SECRET}" -H 'content-type: application/json' -d '{}'` | 400 | ✅ | +| T2 | Wrong secret | T1 with secret `wrong` and a valid body | 401, no email | ✅ | +| T3 | Send now | `curl -i -X POST localhost:3000/api/notifications -H 'content-type: application/json' -d '{"subject":"t3","body":"

t3

","filters":["Dog"]}'` | 202, only Dog volunteers get it | ✅ | +| T4 | Lambda --> backend (owner) | `aws lambda invoke --region ${AWS_REGION} --function-name mspca-notif-dispatch --cli-binary-format raw-in-base64-out --payload '{"subject":"t4","body":"

t4

","filters":["Cat"]}' /tmp/out.json` | `StatusCode 200`, `null` | ✅ | +| T5 | Full chain (owner) | T3 with `"filters":[]` and `"sendAt":"$(date -u -v+2M +%Y-%m-%dT%H:%M:%SZ)"` | 202, all active volunteers get it about 2 min later | ✅ | +| T6 | Retries (owner) | Stop the backend, run T4 with `--invocation-type Event`, then `aws logs tail /aws/lambda/mspca-notif-dispatch --since 10m` | 3 `Dispatch failed` lines, no email | ✅ | +| T7 | Validation | past `sendAt`, `filters:["Horse"]`, missing field, line break in subject | 400 | ✅ | ---- +The dev run was 2026-10-26/27. Unit tests: `npx jest apps/backend/src/notifications`. ## Create from scratch (prod / rebuild) -Only needed for a new environment (prod, staging, another AWS account), or if the dev resources are deleted. For prod, use new names (for example `mspca-notif-dispatch-prod`) and a new secret. +Only for a new environment or if the dev resources are deleted. For prod, use new names and a new secret. You need a CLI login with IAM and Lambda admin rights. -> **zsh users:** always write `${VAR}` with braces. zsh reads `$ACCOUNT_ID:role` as the `:r` modifier and silently produces `...177ole`. - -The CLI identity needs IAM and Lambda admin rights. The backend's SES key is not enough. - -### 0. Shell variables - -These only live in the current terminal tab. Run every step in the same tab. +> **zsh:** write `${VAR}` with braces. `$ACCOUNT_ID:role` gets eaten by zsh's `:r` modifier. ```bash +# 0. shell vars (this tab only; save the secret) export AWS_REGION=us-east-2 export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) -export DISPATCH_SECRET=$(openssl rand -hex 32) # save it: step 5 needs it, and re-running this makes a new one -export DISPATCH_URL=https:///api/notifications/send-scheduled -``` +export DISPATCH_SECRET=$(openssl rand -hex 32) +export DISPATCH_URL=https:///api/notifications/send-scheduled -### 1. Lambda role - -```bash +# 1. lambda role aws iam create-role --role-name mspca-notif-lambda-role \ --assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}' - aws iam attach-role-policy --role-name mspca-notif-lambda-role \ --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole -``` - -Wait about 10s before step 2, until the new role propagates. +sleep 10 # let the role propagate -### 2. Lambda - -```bash +# 2. lambda zip -j /tmp/notif-dispatch.zip infra/notification-dispatch-lambda/index.mjs - -aws lambda create-function --region ${AWS_REGION} \ - --function-name mspca-notif-dispatch \ - --runtime nodejs22.x --handler index.handler \ +aws lambda create-function --region ${AWS_REGION} --function-name mspca-notif-dispatch \ + --runtime nodejs22.x --handler index.handler --timeout 30 \ --zip-file fileb:///tmp/notif-dispatch.zip \ --role arn:aws:iam::${ACCOUNT_ID}:role/mspca-notif-lambda-role \ - --timeout 30 \ --environment "Variables={NOTIFICATIONS_DISPATCH_URL=${DISPATCH_URL},NOTIFICATIONS_DISPATCH_SECRET=${DISPATCH_SECRET}}" - aws lambda put-function-event-invoke-config --region ${AWS_REGION} \ --function-name mspca-notif-dispatch --maximum-retry-attempts 2 -``` -Use this to change the URL or secret later, for example on a new tunnel or for deploy: - -```bash -aws lambda update-function-configuration --region ${AWS_REGION} --function-name mspca-notif-dispatch \ - --environment "Variables={NOTIFICATIONS_DISPATCH_URL=${DISPATCH_URL},NOTIFICATIONS_DISPATCH_SECRET=${DISPATCH_SECRET}}" -``` - -To redeploy after editing `index.mjs`: rebuild the zip, then run `aws lambda update-function-code --function-name mspca-notif-dispatch --zip-file fileb:///tmp/notif-dispatch.zip`. - -### 3. Scheduler role - -```bash +# 3. scheduler role aws iam create-role --role-name mspca-notif-scheduler-role \ --assume-role-policy-document "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"scheduler.amazonaws.com\"},\"Action\":\"sts:AssumeRole\",\"Condition\":{\"StringEquals\":{\"aws:SourceAccount\":\"${ACCOUNT_ID}\"}}}]}" - -aws iam put-role-policy --role-name mspca-notif-scheduler-role \ - --policy-name invoke-notif-dispatch \ +aws iam put-role-policy --role-name mspca-notif-scheduler-role --policy-name invoke-notif-dispatch \ --policy-document "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":\"lambda:InvokeFunction\",\"Resource\":\"arn:aws:lambda:${AWS_REGION}:${ACCOUNT_ID}:function:mspca-notif-dispatch\"}]}" -``` - -### 4. Backend IAM user -```bash -# which IAM user the backend's .env key belongs to +# 4. backend IAM user (the one behind .env's AWS key) export BACKEND_USER=$( (set -a; source .env; aws sts get-caller-identity --query Arn --output text) | sed 's|.*/||' ) - -aws iam put-user-policy --user-name ${BACKEND_USER} \ - --policy-name mspca-notif-create-schedules \ +aws iam put-user-policy --user-name ${BACKEND_USER} --policy-name mspca-notif-create-schedules \ --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 - -```bash +# 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" echo "AWS_SCHEDULER_ROLE_ARN=arn:aws:iam::${ACCOUNT_ID}:role/mspca-notif-scheduler-role" echo "NOTIFICATIONS_DISPATCH_SECRET=${DISPATCH_SECRET}" ``` -Paste these into `.env` (locally) or the host's config (deployed), then restart the backend. It reads env only at startup. - ---- +To change the Lambda's URL or secret later, run `aws lambda update-function-configuration` with the same `--environment` as step 2. After editing `index.mjs`, rezip and run `aws lambda update-function-code --function-name mspca-notif-dispatch --zip-file fileb:///tmp/notif-dispatch.zip`. ## Prod checklist -**Blockers** - -- [ ] **Make `/send-scheduled` skip Cognito.** `CognitoJWTGuard` is a global `APP_GUARD` ([cognito.module.ts](../../apps/backend/src/aws/cognito/cognito.module.ts)), and the route has no `@Public()`. The Lambda has no Cognito token, so once auth is on every dispatch gets a 401. It works locally only because `AUTH_DISABLED=true`. Fix: add `@Public()` to the route; `NotificationsDispatchGuard` still protects it. -- [ ] **Deploy the backend** at a public HTTPS host. The repo has no deploy pipeline yet ([backend_build.yml](../../.github/workflows/backend_build.yml) only builds). -- [ ] **Point the Lambda at prod:** set `NOTIFICATIONS_DISPATCH_URL=https:///api/notifications/send-scheduled` (step 2 update). -- [ ] **Put the backend env vars in the host's config**, never a committed file. Set the 3 vars from step 5 plus `SES_ENABLED=true` and `AWS_SES_SENDER_EMAIL`. - -**Should do** - -- [ ] **Separate prod resources** (`mspca-notif-dispatch-prod`, its own scheduler role, its own secret), so dev testing can never email real volunteers. Rerun steps 0–5 with new names. -- [ ] **Least-privilege backend user.** Prod should use a dedicated IAM user with only SES send plus the step 4 policy, not a personal or admin key. -- [ ] **Send from a verified domain, not Gmail.** The dev Gmail sender goes out "via amazonses.com", and Gmail put a test email in spam. Use an address on a verified domain with DKIM (for example `c4cneu.com`, already verified in SES). -- [ ] **Rotate the dev secret** if it was ever shared or pasted anywhere. Change it on both sides together. -- [ ] **Delete the dev resources** when they're no longer needed (see Cleanup). - -**Known gaps (follow-up tickets)** - -- [ ] **Failed sends aren't retried.** `sendNow` logs failed SES batches and returns normally (`TODO` in [notifications.service.ts](../../apps/backend/src/notifications/notifications.service.ts)), so `/send-scheduled` returns 204 and the Lambda never retries. Lambda retries cover only "backend unreachable or non-2xx". -- [ ] **Invalid stored emails are skipped silently.** `sendNow` drops malformed addresses before sending and only logs a count, because the DB has no email format check. Validate on write, as SSF does. -- [ ] **Retries can double-send.** A retry after a partial send repeats the earlier batches (at-least-once delivery). -- [ ] **Role gating.** Any authenticated user can call `POST /notifications`. It should be coordinators only (see the `TODO` in the controller). -- [ ] **Swap to `ArrayOverlap` when [PR #11](https://github.com/Code-4-Community/mspca/pull/11) merges.** `fosterType` becomes `FosterType[]`, and `In()` on an array column is a SQL error. - -**Retry budget, end to end** - -| Layer | Retries | Configured in | -| ------------------- | ---------------- | ------------------------------------------- | -| Scheduler to Lambda | 2, within 10 min | `RetryPolicy` in `notifications.service.ts` | -| Lambda to backend | 2 (async) | step 2, `put-function-event-invoke-config` | -| Backend to SES | none | known gap above | - -If every retry fails, the failure is only logged in CloudWatch. There is no dead-letter queue. - ---- +- [ ] Add `@Public()` to `/send-scheduled`. The Cognito guard is global, and the Lambda has no token, so it gets a 401 once auth is on. +- [ ] Deploy the backend, point the Lambda's `NOTIFICATIONS_DISPATCH_URL` at it, and put the step 5 vars in the host's config. +- [ ] Use separate prod resources and a new secret, so dev tests can't email real volunteers. +- [ ] Send from a verified domain (for example `c4cneu.com`), not Gmail. Gmail sends land in spam. +- [ ] Follow-ups: failed SES batches aren't retried (only logged); coordinator-only role gating; `ArrayOverlap()` after [#11](https://github.com/Code-4-Community/mspca/pull/11). ## Troubleshooting -| Symptom | Cause | Fix | -| ----------------------------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------- | -| `...:iam::489...177ole/...` regex error | zsh `:r` modifier | Use `${ACCOUNT_ID}` with braces | -| `POST /notifications` with `sendAt` returns 500 | Scheduler env vars unset, or `AccessDenied` in the backend log | Do step 5 and restart; check that the step 4 `iam:PassRole` exists | -| Lambda: `Dispatch failed: 401` | Secrets differ (or, in prod, the Cognito guard) | Use the same secret on both sides; see the `@Public()` blocker | -| Lambda: `Dispatch failed: 404` | Wrong URL path | It must end in `/api/notifications/send-scheduled` | -| Lambda: fetch failed / timeout | Tunnel or backend is down | Restart them; check the URL is still current | -| No Lambda logs at all | Scheduler couldn't invoke it | Check the step 3 role and the Lambda ARN in `.env` | -| `list-schedules`: AccessDenied | Your CLI user lacks Scheduler read access (not needed by the app) | Add `scheduler:ListSchedules` and `scheduler:GetSchedule` to your own user | -| `makeBuilder is not a function` on boot | Stale Nx project graph | Run `nx reset` | +| Symptom | Fix | +| ----------------------------------------------- | --------------------------------------------------------------------------------------- | +| ARN regex error like `...177ole/` | zsh `:r` modifier. Use `${VAR}` braces. | +| `POST /notifications` with `sendAt` returns 500 | Scheduler env vars unset, or step 4 `iam:PassRole` missing. Check the backend log. | +| Lambda log `Dispatch failed: 401` | Secrets differ between `.env` and the Lambda (in prod, also the `@Public()` item above) | +| Lambda log `Dispatch failed: 404` / `502` | Wrong URL path, or the tunnel/backend is down | +| No Lambda logs at all | Scheduler can't invoke it. Check the step 3 role and the ARN in `.env`. | ## Cleanup @@ -253,11 +127,4 @@ aws iam delete-role --role-name mspca-notif-scheduler-role aws iam delete-user-policy --user-name ${BACKEND_USER} --policy-name mspca-notif-create-schedules ``` -## Refs - -- [EventBridge Scheduler: one-time `at()` schedules](https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html#one-time) -- [Scheduler execution role](https://docs.aws.amazon.com/scheduler/latest/UserGuide/setting-up.html#setting-up-execution-role) -- [Scheduler retry policy](https://docs.aws.amazon.com/scheduler/latest/UserGuide/managing-schedule-retries.html) -- [Lambda async invocation and retries](https://docs.aws.amazon.com/lambda/latest/dg/invocation-async.html) -- [`iam:PassRole`](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_passrole.html) -- [SES sandbox vs production access](https://docs.aws.amazon.com/ses/latest/dg/request-production-access.html) +Refs: [Scheduler one-time schedules](https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html#one-time) · [Scheduler execution role](https://docs.aws.amazon.com/scheduler/latest/UserGuide/setting-up.html#setting-up-execution-role) · [Lambda async retries](https://docs.aws.amazon.com/lambda/latest/dg/invocation-async.html) · [`iam:PassRole`](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_passrole.html) From 88c97f7aa0fabb280d28722b304eb21400fda13b Mon Sep 17 00:00:00 2001 From: yurikakan Date: Tue, 27 Oct 2026 21:11:23 -0400 Subject: [PATCH 10/11] addressed comments --- .../notifications/notifications.controller.ts | 1 - .../notifications.dispatch.guard.spec.ts | 11 +++++- .../notifications.dispatch.guard.ts | 1 - .../notifications.service.spec.ts | 36 +++++++++++++++++++ .../notifications/notifications.service.ts | 9 ++++- .../src/volunteers/volunteers.service.spec.ts | 31 ++++++++++++++++ .../src/volunteers/volunteers.service.ts | 2 -- 7 files changed, 85 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/notifications/notifications.controller.ts b/apps/backend/src/notifications/notifications.controller.ts index f8cd11827..ac414be2b 100644 --- a/apps/backend/src/notifications/notifications.controller.ts +++ b/apps/backend/src/notifications/notifications.controller.ts @@ -22,7 +22,6 @@ import { NotificationsDispatchGuard } from './notifications.dispatch.guard'; export class NotificationsController { constructor(private notificationsService: NotificationsService) {} - // TODO: role/cognito auth: possible considerations of how roles are modelled (cognito groups vs db role) @Post() @HttpCode(HttpStatus.ACCEPTED) @ApiOperation({ diff --git a/apps/backend/src/notifications/notifications.dispatch.guard.spec.ts b/apps/backend/src/notifications/notifications.dispatch.guard.spec.ts index 42055d7b6..9f1ca7442 100644 --- a/apps/backend/src/notifications/notifications.dispatch.guard.spec.ts +++ b/apps/backend/src/notifications/notifications.dispatch.guard.spec.ts @@ -13,12 +13,22 @@ const contextWith = (headers: Record) => describe('NotificationsDispatchGuard', () => { let guard: NotificationsDispatchGuard; + let originalSecret: string | undefined; beforeEach(() => { + originalSecret = process.env.NOTIFICATIONS_DISPATCH_SECRET; process.env.NOTIFICATIONS_DISPATCH_SECRET = SECRET; guard = new NotificationsDispatchGuard(); }); + afterEach(() => { + if (originalSecret === undefined) { + delete process.env.NOTIFICATIONS_DISPATCH_SECRET; + } else { + process.env.NOTIFICATIONS_DISPATCH_SECRET = originalSecret; + } + }); + it('allows a request presenting the configured secret', () => { expect( guard.canActivate(contextWith({ [DISPATCH_SECRET_HEADER]: SECRET })), @@ -39,7 +49,6 @@ describe('NotificationsDispatchGuard', () => { ); }); - // An unset secret must never mean "let everyone through". it('fails closed when no secret is configured', () => { delete process.env.NOTIFICATIONS_DISPATCH_SECRET; diff --git a/apps/backend/src/notifications/notifications.dispatch.guard.ts b/apps/backend/src/notifications/notifications.dispatch.guard.ts index 852d0b96c..da5a9deaa 100644 --- a/apps/backend/src/notifications/notifications.dispatch.guard.ts +++ b/apps/backend/src/notifications/notifications.dispatch.guard.ts @@ -30,7 +30,6 @@ export class NotificationsDispatchGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const expected = process.env.NOTIFICATIONS_DISPATCH_SECRET; - // Fail closed: an unset secret must never mean "let everyone through". if (!expected) { this.logger.error( 'NOTIFICATIONS_DISPATCH_SECRET is not configured. Rejecting all dispatch requests.', diff --git a/apps/backend/src/notifications/notifications.service.spec.ts b/apps/backend/src/notifications/notifications.service.spec.ts index 3254efbcb..01c6c119e 100644 --- a/apps/backend/src/notifications/notifications.service.spec.ts +++ b/apps/backend/src/notifications/notifications.service.spec.ts @@ -24,11 +24,22 @@ describe('NotificationsService', () => { const dto = { subject: 'Fosters needed', body: '

Please help.

' }; + const ENV_VARS = [ + 'AWS_SES_SENDER_EMAIL', + 'AWS_NOTIFICATION_DISPATCH_LAMBDA_ARN', + 'AWS_SCHEDULER_ROLE_ARN', + ] as const; + const originalEnv: Record = {}; + /** The bccEmails array of each sendEmail call, in order. */ const sentBatches = (): string[][] => emailsService.sendEmail.mock.calls.map((c) => c[0].bccEmails); beforeEach(async () => { + for (const name of ENV_VARS) { + originalEnv[name] = process.env[name]; + } + process.env.AWS_SES_SENDER_EMAIL = SENDER; process.env.AWS_NOTIFICATION_DISPATCH_LAMBDA_ARN = 'arn:aws:lambda:::fn'; @@ -52,6 +63,16 @@ describe('NotificationsService', () => { service = module.get(NotificationsService); }); + afterEach(() => { + for (const name of ENV_VARS) { + if (originalEnv[name] === undefined) { + delete process.env[name]; + } else { + process.env[name] = originalEnv[name]; + } + } + }); + it('should be defined', () => { expect(service).toBeDefined(); }); @@ -185,6 +206,21 @@ describe('NotificationsService', () => { expect(emailsService.sendEmail).toHaveBeenCalledTimes(2); }); + it('fails when every batch fails to send', async () => { + volunteersService.findActiveByFosterTypes.mockResolvedValue( + volunteers(98), + ); + emailsService.sendEmail.mockRejectedValue( + new Error('SES rejected the send'), + ); + + await expect(service.sendNow(dto)).rejects.toThrow( + InternalServerErrorException, + ); + + expect(emailsService.sendEmail).toHaveBeenCalledTimes(2); + }); + it('sends nothing when no volunteers match', async () => { volunteersService.findActiveByFosterTypes.mockResolvedValue([]); diff --git a/apps/backend/src/notifications/notifications.service.ts b/apps/backend/src/notifications/notifications.service.ts index 2703ae407..c21eb50ea 100644 --- a/apps/backend/src/notifications/notifications.service.ts +++ b/apps/backend/src/notifications/notifications.service.ts @@ -114,7 +114,8 @@ export class NotificationsService { * rest of the send. * * @param content - The subject, HTML body, and foster type filters. - * @throws {InternalServerErrorException} If AWS_SES_SENDER_EMAIL is not configured. + * @throws {InternalServerErrorException} If AWS_SES_SENDER_EMAIL is not configured, + * or if every batch fails to send. */ async sendNow(content: CreateBulkNotificationDto): Promise { const sender = process.env.AWS_SES_SENDER_EMAIL; @@ -170,6 +171,12 @@ export class NotificationsService { } } + if (failed === recipients.length) { + throw new InternalServerErrorException( + `Bulk notification failed: none of ${recipients.length} recipient(s) were emailed`, + ); + } + // TODO: implement retry mechanism for failed email sends // failed batches are logged & never retried, caller still gets 202 if (failed > 0) { diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts index c12184bd6..be2ab1457 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -7,6 +7,7 @@ import { NotFoundException, } from '@nestjs/common'; import { getRepositoryToken } from '@nestjs/typeorm'; +import { In } from 'typeorm'; import { VolunteersService } from './volunteers.service'; import { FosterVolunteer } from './volunteers.entity'; import { FosterType, VolunteerStatus } from './volunteers.types'; @@ -889,4 +890,34 @@ describe('VolunteersService', () => { expect(mockRepo.save).not.toHaveBeenCalled(); }); }); + + describe('findActiveByFosterTypes', () => { + it('should return active volunteers matching any of the foster types', async () => { + mockRepo.find.mockResolvedValue(mockVolunteers); + + const result = await service.findActiveByFosterTypes([ + FosterType.DOG, + FosterType.CAT, + ]); + + expect(result).toEqual(mockVolunteers); + expect(mockRepo.find).toHaveBeenCalledWith({ + where: { + status: VolunteerStatus.ACTIVE, + fosterType: In([FosterType.DOG, FosterType.CAT]), + }, + }); + }); + + it('should return every active volunteer when no foster types are given', async () => { + mockRepo.find.mockResolvedValue(mockVolunteers); + + const result = await service.findActiveByFosterTypes([]); + + expect(result).toEqual(mockVolunteers); + expect(mockRepo.find).toHaveBeenCalledWith({ + where: { status: VolunteerStatus.ACTIVE }, + }); + }); + }); }); diff --git a/apps/backend/src/volunteers/volunteers.service.ts b/apps/backend/src/volunteers/volunteers.service.ts index e382dbe12..4497f132c 100644 --- a/apps/backend/src/volunteers/volunteers.service.ts +++ b/apps/backend/src/volunteers/volunteers.service.ts @@ -377,8 +377,6 @@ export class VolunteersService { async findActiveByFosterTypes( fosterTypes: FosterType[], ): Promise { - // shortcut: fosterType is a single enum today, so In() is an exact match. - // Once PR #11 makes the column an array this needs the && overlap operator. return this.repo.find({ where: { status: VolunteerStatus.ACTIVE, From 912b7acf4c7b84a07b0bc90837201e049fb0e4cd Mon Sep 17 00:00:00 2001 From: yurikakan Date: Sat, 7 Nov 2026 23:34:48 -0500 Subject: [PATCH 11/11] refactor(notifications): move dispatch lambda under aws/scheduler and 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 --- .../src/aws/scheduler/lambda/notif}/README.md | 7 +-- .../src/aws/scheduler/lambda/notif}/index.mjs | 10 ++-- .../notifications.module.spec.ts | 36 ++++--------- .../src/notifications/notifications.module.ts | 32 ++++++----- .../notifications/notifications.service.ts | 12 ++--- .../src/volunteers/volunteers.service.spec.ts | 54 ++++++++++++++++--- example.env | 16 +++--- package.json | 1 + 8 files changed, 96 insertions(+), 72 deletions(-) rename {infra/notification-dispatch-lambda => apps/backend/src/aws/scheduler/lambda/notif}/README.md (96%) rename {infra/notification-dispatch-lambda => apps/backend/src/aws/scheduler/lambda/notif}/index.mjs (77%) diff --git a/infra/notification-dispatch-lambda/README.md b/apps/backend/src/aws/scheduler/lambda/notif/README.md similarity index 96% rename from infra/notification-dispatch-lambda/README.md rename to apps/backend/src/aws/scheduler/lambda/notif/README.md index c4605c43a..303a1fa32 100644 --- a/infra/notification-dispatch-lambda/README.md +++ b/apps/backend/src/aws/scheduler/lambda/notif/README.md @@ -9,7 +9,8 @@ scheduled: POST /notifications --> Scheduler (waits until sendAt) --> Lambda -- - Recipients are looked up at send time, so volunteers who went inactive in between are left out. - The schedule deletes itself after it fires. -- Code: [notifications/](../../apps/backend/src/notifications) · [index.mjs](./index.mjs) +- Code: [notifications/](../../../../notifications) · [index.mjs](./index.mjs) +- `index.mjs` is deployed to Lambda on its own (see "Create from scratch"). It is not imported by the backend and not part of its bundle. ## Resources (dev, `us-east-2`) @@ -22,7 +23,7 @@ scheduled: POST /notifications --> Scheduler (waits until sendAt) --> Lambda -- ## Connect to the existing dev setup -Nothing to create. Immediate sends need only the usual SES vars. Scheduled sends also need these 3 in `.env`. Ask the ticket owner by DM, and never commit them: +Nothing to create. The backend won't boot without these 3 in `.env`. Ask the ticket owner by DM, and never commit them: ``` AWS_NOTIFICATION_DISPATCH_LAMBDA_ARN= @@ -70,7 +71,7 @@ aws iam attach-role-policy --role-name mspca-notif-lambda-role \ sleep 10 # let the role propagate # 2. lambda -zip -j /tmp/notif-dispatch.zip infra/notification-dispatch-lambda/index.mjs +zip -j /tmp/notif-dispatch.zip apps/backend/src/aws/scheduler/lambda/notif/index.mjs aws lambda create-function --region ${AWS_REGION} --function-name mspca-notif-dispatch \ --runtime nodejs22.x --handler index.handler --timeout 30 \ --zip-file fileb:///tmp/notif-dispatch.zip \ diff --git a/infra/notification-dispatch-lambda/index.mjs b/apps/backend/src/aws/scheduler/lambda/notif/index.mjs similarity index 77% rename from infra/notification-dispatch-lambda/index.mjs rename to apps/backend/src/aws/scheduler/lambda/notif/index.mjs index ee84cc378..2599ca57e 100644 --- a/infra/notification-dispatch-lambda/index.mjs +++ b/apps/backend/src/aws/scheduler/lambda/notif/index.mjs @@ -9,7 +9,7 @@ * NOTIFICATIONS_DISPATCH_URL https:///api/notifications/send-scheduled * NOTIFICATIONS_DISPATCH_SECRET must match the backend's value */ -export const handler = async (event) => { +export const handler = async (event, context) => { const url = process.env.NOTIFICATIONS_DISPATCH_URL; const secret = process.env.NOTIFICATIONS_DISPATCH_SECRET; @@ -28,11 +28,13 @@ export const handler = async (event) => { body: JSON.stringify(event), }); - // Scheduler invokes this Lambda asynchronously, so throwing triggers Lambda's async - // retries (up to 2 by default, set in the function's async invocation config). + // Scheduler invokes this Lambda asynchronously, so throwing triggers Lambda's async set 2 default + // Lambda doesn't pass attempt number, but request ID stays the same across retries so count those if (!response.ok) { throw new Error( - `Dispatch failed: ${response.status} ${await response.text()}`, + `Dispatch failed (request ${context.awsRequestId}): ${ + response.status + } ${await response.text()}`, ); } }; diff --git a/apps/backend/src/notifications/notifications.module.spec.ts b/apps/backend/src/notifications/notifications.module.spec.ts index 8a9644e67..a34a0230b 100644 --- a/apps/backend/src/notifications/notifications.module.spec.ts +++ b/apps/backend/src/notifications/notifications.module.spec.ts @@ -5,10 +5,11 @@ import { NotificationsModule } from './notifications.module'; jest.mock('jwks-rsa', () => ({ __esModule: true, default: jest.fn() })); describe('NotificationsModule', () => { - const REQUIRED_FOR_SCHEDULING = [ + const REQUIRED = [ 'AWS_REGION', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', + 'AWS_SES_SENDER_EMAIL', 'AWS_NOTIFICATION_DISPATCH_LAMBDA_ARN', 'AWS_SCHEDULER_ROLE_ARN', 'NOTIFICATIONS_DISPATCH_SECRET', @@ -19,7 +20,7 @@ describe('NotificationsModule', () => { let logSpy: jest.SpyInstance; beforeEach(() => { - for (const name of REQUIRED_FOR_SCHEDULING) { + for (const name of REQUIRED) { originalEnv[name] = process.env[name]; } @@ -27,6 +28,7 @@ describe('NotificationsModule', () => { process.env.AWS_REGION = 'us-east-2'; process.env.AWS_ACCESS_KEY_ID = 'test-access-key-id'; process.env.AWS_SECRET_ACCESS_KEY = 'test-secret-access-key'; + process.env.AWS_SES_SENDER_EMAIL = 'sender@example.com'; process.env.AWS_NOTIFICATION_DISPATCH_LAMBDA_ARN = 'arn:aws:lambda:::fn'; process.env.AWS_SCHEDULER_ROLE_ARN = 'arn:aws:iam:::role/scheduler'; process.env.NOTIFICATIONS_DISPATCH_SECRET = 'test-secret'; @@ -38,7 +40,7 @@ describe('NotificationsModule', () => { }); afterEach(() => { - for (const name of REQUIRED_FOR_SCHEDULING) { + for (const name of REQUIRED) { if (originalEnv[name] === undefined) { delete process.env[name]; } else { @@ -48,40 +50,22 @@ describe('NotificationsModule', () => { jest.restoreAllMocks(); }); - it('reports scheduling as enabled when every var is set', () => { + it('logs that notifications are enabled when every var is set', () => { module.onModuleInit(); - expect(logSpy).toHaveBeenCalledWith('Scheduled notifications enabled'); + expect(logSpy).toHaveBeenCalledWith('Notifications enabled'); }); - it.each(REQUIRED_FOR_SCHEDULING)('names %s when it is missing', (name) => { + it.each(REQUIRED)('throws naming %s when it is missing', (name) => { delete process.env[name]; - module.onModuleInit(); - - expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining(`missing env vars (${name})`), - ); + expect(() => module.onModuleInit()).toThrow(`missing env vars (${name})`); }); // Whitespace is as unusable as unset, and getMissingEnvVars treats it that way. it('counts a whitespace-only var as missing', () => { process.env.AWS_SCHEDULER_ROLE_ARN = ' '; - module.onModuleInit(); - - expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining('AWS_SCHEDULER_ROLE_ARN'), - ); - }); - - it('says immediate sends still work when scheduling is unconfigured', () => { - delete process.env.AWS_SCHEDULER_ROLE_ARN; - - module.onModuleInit(); - - expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining('Immediate sends still work'), - ); + expect(() => module.onModuleInit()).toThrow('AWS_SCHEDULER_ROLE_ARN'); }); }); diff --git a/apps/backend/src/notifications/notifications.module.ts b/apps/backend/src/notifications/notifications.module.ts index f8135cd6e..689f0db37 100644 --- a/apps/backend/src/notifications/notifications.module.ts +++ b/apps/backend/src/notifications/notifications.module.ts @@ -6,13 +6,13 @@ import { VolunteersModule } from '../volunteers/volunteers.module'; import { AWSSESModule } from '../aws/ses/email.module'; import { getMissingEnvVars } from '../utils/env'; -// Env vars needed only to schedule a notification for a future time. Sending one -// immediately needs none of these, just the SES config the AWSSESModule checks. -// AWS_REGION and the credentials are the shared AWS ones (also used by S3 and SES). -const REQUIRED_ENV_VARS_FOR_SCHEDULING = [ +// Env vars notifications need to send and schedule. AWS_REGION and the credentials +// are the shared AWS ones (also used by S3 and SES). +const REQUIRED_ENV_VARS = [ 'AWS_REGION', 'AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', + 'AWS_SES_SENDER_EMAIL', 'AWS_NOTIFICATION_DISPATCH_LAMBDA_ARN', 'AWS_SCHEDULER_ROLE_ARN', 'NOTIFICATIONS_DISPATCH_SECRET', @@ -27,24 +27,22 @@ export class NotificationsModule implements OnModuleInit { private readonly logger = new Logger(NotificationsModule.name); /** - * Reports at startup whether scheduled notifications are usable. + * Checks at startup that every env var notifications need is set. * - * There is no enabled flag: having the vars set is what turns scheduling on. This - * surfaces a half-configured environment in the boot log rather than as a 500 the - * first time a coordinator picks a send time. + * Notifications are always enabled, so a missing var fails the boot rather than + * surfacing as a 500 the first time a coordinator sends a notification. + * + * @throws {Error} If any required env var is missing or blank. */ onModuleInit(): void { - const missing = getMissingEnvVars(REQUIRED_ENV_VARS_FOR_SCHEDULING); + const missing = getMissingEnvVars(REQUIRED_ENV_VARS); - if (missing.length === 0) { - this.logger.log('Scheduled notifications enabled'); - return; + if (missing.length > 0) { + throw new Error( + `Notifications misconfigured: missing env vars (${missing.join(', ')})`, + ); } - this.logger.log( - `Scheduled notifications disabled: missing env vars (${missing.join( - ', ', - )}). Immediate sends still work; POST /notifications with a sendAt will fail.`, - ); + this.logger.log('Notifications enabled'); } } diff --git a/apps/backend/src/notifications/notifications.service.ts b/apps/backend/src/notifications/notifications.service.ts index c21eb50ea..c8708f6de 100644 --- a/apps/backend/src/notifications/notifications.service.ts +++ b/apps/backend/src/notifications/notifications.service.ts @@ -17,10 +17,6 @@ import { VolunteersService } from '../volunteers/volunteers.service'; import { CreateBulkNotificationDto } from './dtos/create-bulk-notification.dto'; import { AMAZON_SCHEDULER_CLIENT } from './awsScheduler.factory'; -// SES caps a message at 50 recipients across To, Cc and Bcc combined, and the -// sender always occupies the To slot, so only 49 are left for Bcc. -const BCC_BATCH_SIZE = 49; - // Mirrors SendEmailDTO's @Length(1, 255) on each Bcc address. const MAX_EMAIL_LENGTH = 255; @@ -125,6 +121,8 @@ export class NotificationsService { ); } + // TODO: adjust once notification filters are confirmed with the client; they will + // likely be more complex than foster type alone const volunteers = await this.volunteersService.findActiveByFosterTypes( content.filters ?? [], ); @@ -152,8 +150,10 @@ export class NotificationsService { } let failed = 0; - for (let i = 0; i < recipients.length; i += BCC_BATCH_SIZE) { - const batch = recipients.slice(i, i + BCC_BATCH_SIZE); + // SES caps a message at 50 recipients across To, Cc and Bcc combined, and the + // sender always occupies the To slot, so only 49 are left for Bcc. + for (let i = 0; i < recipients.length; i += 49) { + const batch = recipients.slice(i, i + 49); try { await this.emailsService.sendEmail({ diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts index 81ce0be3f..48722f690 100644 --- a/apps/backend/src/volunteers/volunteers.service.spec.ts +++ b/apps/backend/src/volunteers/volunteers.service.spec.ts @@ -7,7 +7,7 @@ import { NotFoundException, } from '@nestjs/common'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { ArrayOverlap } from 'typeorm'; +import { ArrayOverlap, FindOperator } from 'typeorm'; import { VolunteersService } from './volunteers.service'; import { FosterVolunteer } from './volunteers.entity'; import { @@ -54,8 +54,20 @@ describe('VolunteersService', () => { let mockAnimalsService: { checkAllExistOrFail: jest.Mock }; const mockVolunteers = [ - { volunteerId: 1, firstName: 'Jane', lastName: 'Doe' }, - { volunteerId: 2, firstName: 'John', lastName: 'Smith' }, + { + volunteerId: 1, + firstName: 'Jane', + lastName: 'Doe', + fosterType: [FosterType.DOG], + status: VolunteerStatus.ACTIVE, + }, + { + volunteerId: 2, + firstName: 'John', + lastName: 'Smith', + fosterType: [FosterType.CAT], + status: VolunteerStatus.ACTIVE, + }, ] as FosterVolunteer[]; beforeEach(async () => { @@ -1549,9 +1561,33 @@ describe('VolunteersService', () => { }); describe('findActiveByFosterTypes', () => { - it('should return active volunteers matching any of the foster types', async () => { - mockRepo.find.mockResolvedValue(mockVolunteers); + const inactiveVolunteer = { + volunteerId: 3, + firstName: 'Ann', + lastName: 'Lee', + fosterType: [FosterType.DOG], + status: VolunteerStatus.INACTIVE, + } as FosterVolunteer; + + beforeEach(() => { + mockRepo.find.mockImplementation( + ({ where }: { where: Partial> }) => + Promise.resolve( + [...mockVolunteers, inactiveVolunteer].filter( + (v) => + v.status === where.status && + (!where.fosterType || + v.fosterType.some((t) => + ( + where.fosterType as FindOperator + ).value.includes(t), + )), + ), + ), + ); + }); + it('should return active volunteers matching any of the foster types', async () => { const result = await service.findActiveByFosterTypes([ FosterType.DOG, FosterType.CAT, @@ -1566,9 +1602,13 @@ describe('VolunteersService', () => { }); }); - it('should return every active volunteer when no foster types are given', async () => { - mockRepo.find.mockResolvedValue(mockVolunteers); + it('should return only volunteers with a matching foster type', async () => { + const result = await service.findActiveByFosterTypes([FosterType.CAT]); + + expect(result).toEqual([mockVolunteers[1]]); + }); + it('should return every active volunteer when no foster types are given', async () => { const result = await service.findActiveByFosterTypes([]); expect(result).toEqual(mockVolunteers); diff --git a/example.env b/example.env index 9d61e7030..4b88d702d 100644 --- a/example.env +++ b/example.env @@ -47,12 +47,10 @@ COGNITO_CLIENT_ID= COGNITO_REGION= # --- Scheduled notifications (EventBridge Scheduler) --- -# Only needed to schedule a notification for a future time (POST /notifications -# with sendAt). Immediate sends don't touch any of these. -# The ARN of the Lambda in infra/notification-dispatch-lambda that Scheduler invokes: -# 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 -# Lambda's own copy. Generate with: openssl rand -hex 32 -# NOTIFICATIONS_DISPATCH_SECRET= +# Required: apps/backend/src/aws/scheduler/lambda/notif/README.md +# Lambda that Scheduler invokes at sendAt +AWS_NOTIFICATION_DISPATCH_LAMBDA_ARN= +# Role the Scheduler assumes to invoke that Lambda +AWS_SCHEDULER_ROLE_ARN= +# Lambda sends this to /notifications/send-scheduled; must match the Lambda's value +NOTIFICATIONS_DISPATCH_SECRET= \ No newline at end of file diff --git a/package.json b/package.json index b3688aede..00dd79ed4 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "lint:check": "eslint apps/frontend --ext .ts,.tsx && eslint apps/backend --ext .ts,.tsx", "lint": "eslint apps/frontend --ext .ts,.tsx --fix && eslint apps/backend --ext .ts,.tsx --fix", "test": "jest --runInBand", + "serve:backend": "bash scripts/serve-backend.sh", "prepush": "yarn run format:check && yarn run lint:check", "prepush:fix": "yarn run format && yarn run lint", "prepare": "husky install",