diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts
index 0885e84d8..1dec7a36a 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';
import { AnimalsModule } from './animals/animals.module';
@Module({
@@ -25,6 +26,7 @@ import { AnimalsModule } from './animals/animals.module';
MatchesModule,
CoordinatorsModule,
VolunteersModule,
+ NotificationsModule,
AnimalsModule,
],
})
diff --git a/apps/backend/src/aws/scheduler/lambda/notif/README.md b/apps/backend/src/aws/scheduler/lambda/notif/README.md
new file mode 100644
index 000000000..303a1fa32
--- /dev/null
+++ b/apps/backend/src/aws/scheduler/lambda/notif/README.md
@@ -0,0 +1,131 @@
+# Scheduled notifications: AWS setup
+
+`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.
+
+```
+immediate: POST /notifications --> sendNow() --> SES
+scheduled: POST /notifications --> Scheduler (waits until sendAt) --> Lambda --> POST /notifications/send-scheduled --> sendNow() --> SES
+```
+
+- 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/](../../../../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`)
+
+| 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 |
+
+## Connect to the existing dev setup
+
+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=
+AWS_SCHEDULER_ROLE_ARN=
+NOTIFICATIONS_DISPATCH_SECRET= # same value as the Lambda's
+```
+
+> 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.
+
+## Testing
+
+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.
+
+| # | 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 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:** 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)
+export DISPATCH_URL=https:///api/notifications/send-scheduled
+
+# 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
+sleep 10 # let the role propagate
+
+# 2. lambda
+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 \
+ --role arn:aws:iam::${ACCOUNT_ID}:role/mspca-notif-lambda-role \
+ --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
+
+# 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 \
+ --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 (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 \
+ --policy-document "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":\"scheduler:CreateSchedule\",\"Resource\":\"arn:aws:scheduler:${AWS_REGION}:${ACCOUNT_ID}:schedule/default/notification-*\"},{\"Effect\":\"Allow\",\"Action\":\"iam:PassRole\",\"Resource\":\"arn:aws:iam::${ACCOUNT_ID}:role/mspca-notif-scheduler-role\",\"Condition\":{\"StringEquals\":{\"iam:PassedToService\":\"scheduler.amazonaws.com\"}}}]}"
+
+# 5. backend env: paste into .env or the host's config, then restart
+echo "AWS_NOTIFICATION_DISPATCH_LAMBDA_ARN=arn:aws:lambda:${AWS_REGION}:${ACCOUNT_ID}:function:mspca-notif-dispatch"
+echo "AWS_SCHEDULER_ROLE_ARN=arn:aws:iam::${ACCOUNT_ID}:role/mspca-notif-scheduler-role"
+echo "NOTIFICATIONS_DISPATCH_SECRET=${DISPATCH_SECRET}"
+```
+
+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
+
+- [ ] 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 | 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
+
+```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: [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)
diff --git a/apps/backend/src/aws/scheduler/lambda/notif/index.mjs b/apps/backend/src/aws/scheduler/lambda/notif/index.mjs
new file mode 100644
index 000000000..2599ca57e
--- /dev/null
+++ b/apps/backend/src/aws/scheduler/lambda/notif/index.mjs
@@ -0,0 +1,40 @@
+/**
+ * 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, context) => {
+ 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 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 (request ${context.awsRequestId}): ${
+ response.status
+ } ${await response.text()}`,
+ );
+ }
+};
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..ac414be2b
--- /dev/null
+++ b/apps/backend/src/notifications/notifications.controller.ts
@@ -0,0 +1,68 @@
+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) {}
+
+ @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..9f1ca7442
--- /dev/null
+++ b/apps/backend/src/notifications/notifications.dispatch.guard.spec.ts
@@ -0,0 +1,59 @@
+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;
+ 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 })),
+ ).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,
+ );
+ });
+
+ 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..da5a9deaa
--- /dev/null
+++ b/apps/backend/src/notifications/notifications.dispatch.guard.ts
@@ -0,0 +1,58 @@
+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;
+
+ 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..a34a0230b
--- /dev/null
+++ b/apps/backend/src/notifications/notifications.module.spec.ts
@@ -0,0 +1,71 @@
+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 = [
+ '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',
+ ] as const;
+
+ const originalEnv: Record = {};
+ let module: NotificationsModule;
+ let logSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ for (const name of REQUIRED) {
+ 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_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';
+
+ module = new NotificationsModule();
+ logSpy = jest
+ .spyOn(module['logger'], 'log')
+ .mockImplementation(() => undefined);
+ });
+
+ afterEach(() => {
+ for (const name of REQUIRED) {
+ if (originalEnv[name] === undefined) {
+ delete process.env[name];
+ } else {
+ process.env[name] = originalEnv[name];
+ }
+ }
+ jest.restoreAllMocks();
+ });
+
+ it('logs that notifications are enabled when every var is set', () => {
+ module.onModuleInit();
+
+ expect(logSpy).toHaveBeenCalledWith('Notifications enabled');
+ });
+
+ it.each(REQUIRED)('throws naming %s when it is missing', (name) => {
+ delete process.env[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 = ' ';
+
+ 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
new file mode 100644
index 000000000..689f0db37
--- /dev/null
+++ b/apps/backend/src/notifications/notifications.module.ts
@@ -0,0 +1,48 @@
+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 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',
+] as const;
+
+@Module({
+ imports: [VolunteersModule, AWSSESModule],
+ controllers: [NotificationsController],
+ providers: [NotificationsService, AmazonSchedulerClientFactory],
+})
+export class NotificationsModule implements OnModuleInit {
+ private readonly logger = new Logger(NotificationsModule.name);
+
+ /**
+ * Checks at startup that every env var notifications need is set.
+ *
+ * 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);
+
+ if (missing.length > 0) {
+ throw new Error(
+ `Notifications misconfigured: missing env vars (${missing.join(', ')})`,
+ );
+ }
+
+ this.logger.log('Notifications enabled');
+ }
+}
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..01c6c119e
--- /dev/null
+++ b/apps/backend/src/notifications/notifications.service.spec.ts
@@ -0,0 +1,315 @@
+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.
' };
+
+ 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';
+ 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);
+ });
+
+ 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();
+ });
+
+ 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('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([]);
+
+ 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..c8708f6de
--- /dev/null
+++ b/apps/backend/src/notifications/notifications.service.ts
@@ -0,0 +1,188 @@
+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';
+
+// 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,
+ * or if every batch fails to send.
+ */
+ 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',
+ );
+ }
+
+ // 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 ?? [],
+ );
+ // 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;
+ // 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({
+ 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),
+ );
+ }
+ }
+
+ 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) {
+ this.logger.warn(
+ `Bulk notification partially sent: ${failed} of ${recipients.length} recipient(s) not emailed`,
+ );
+ }
+ }
+}
diff --git a/apps/backend/src/volunteers/volunteers.service.spec.ts b/apps/backend/src/volunteers/volunteers.service.spec.ts
index ba9d05594..48722f690 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 { ArrayOverlap, FindOperator } from 'typeorm';
import { VolunteersService } from './volunteers.service';
import { FosterVolunteer } from './volunteers.entity';
import {
@@ -53,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 () => {
@@ -1546,4 +1559,62 @@ describe('VolunteersService', () => {
expect(mockRepo.save).not.toHaveBeenCalled();
});
});
+
+ describe('findActiveByFosterTypes', () => {
+ 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,
+ ]);
+
+ expect(result).toEqual(mockVolunteers);
+ expect(mockRepo.find).toHaveBeenCalledWith({
+ where: {
+ status: VolunteerStatus.ACTIVE,
+ fosterType: ArrayOverlap([FosterType.DOG, FosterType.CAT]),
+ },
+ });
+ });
+
+ 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);
+ 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 d8d28d76b..329f91000 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 { ArrayOverlap, Repository } from 'typeorm';
import { FosterVolunteer } from './volunteers.entity';
-import { VolunteerStatus } from './volunteers.types';
+import { FosterType, VolunteerStatus } from './volunteers.types';
import { VolunteerWithoutPrivateFields } from './dtos/volunteer-without-private-fields.dto';
import { CreateVolunteerDto } from './dtos/create-volunteer.dto';
import { ConfirmVolunteerDto } from './dtos/confirm-volunteer.dto';
@@ -543,4 +543,26 @@ export class VolunteersService {
const saved = await this.repo.save(volunteer);
return saved.animalPreferences;
}
+
+ /**
+ * Gets every active Volunteer with at least one foster type in `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 {
+ return this.repo.find({
+ where: {
+ status: VolunteerStatus.ACTIVE,
+ ...(fosterTypes.length > 0 && {
+ fosterType: ArrayOverlap(fosterTypes),
+ }),
+ },
+ });
+ }
}
diff --git a/example.env b/example.env
index f69209252..4b88d702d 100644
--- a/example.env
+++ b/example.env
@@ -45,3 +45,12 @@ COGNITO_USER_POOL_ID=
COGNITO_CLIENT_ID=
# Optional: derived from the user pool ID (_) when left empty.
COGNITO_REGION=
+
+# --- Scheduled notifications (EventBridge Scheduler) ---
+# 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 74e48863b..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",
@@ -22,6 +23,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"