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"