TypeScript Node.js service scaffold for timezone-safe birthday notifications.
- Node.js + TypeScript
- Fastify
- PostgreSQL
- AWS SQS (via LocalStack for local development)
- Vitest
src/
app/
api/
planner/
worker/
domain/
infrastructure/
aws/
config/
db/
http/
logging/
tests/
- Node.js 20+
- npm
- Docker + Docker Compose
- AWS CLI (for LocalStack queue setup)
- Terraform
Copy .env.example to .env and adjust values as needed.
Key defaults:
- API port:
3000 - PostgreSQL:
postgres://postgres:postgres@localhost:5432/birthday_service - LocalStack endpoint:
http://localhost:4566 - queue URL placeholder:
http://localhost:4566/000000000000/birthday-delivery-queue - DLQ name:
birthday-delivery-dlq - SQS redrive defaults:
maxReceiveCount=5,VisibilityTimeout=30,MessageRetentionPeriod=1209600 - Projector polling:
PROJECTOR_POLL_INTERVAL_SECONDS=10,PROJECTOR_BATCH_SIZE=200
npm installdocker compose up -d postgres localstacknpm run infra:queuesThis runs Terraform from infrastructure/terraform/localstack-sqs and provisions:
- main queue:
birthday-delivery-queue - dead-letter queue:
birthday-delivery-dlq
It also configures redrive defaults:
maxReceiveCount=5VisibilityTimeout=30MessageRetentionPeriod=1209600- and sets LocalStack-safe dummy AWS credentials automatically if they are not already defined.
Equivalent direct Terraform commands:
cd infrastructure/terraform/localstack-sqs
terraform init
terraform apply -auto-approve \
-var='aws_endpoint_url=http://localhost:4566' \
-var='aws_region=ap-southeast-2'Verify queue attributes:
aws --endpoint-url=http://localhost:4566 --region ap-southeast-2 sqs get-queue-attributes \
--queue-url "$SQS_BIRTHDAY_QUEUE_URL" \
--attribute-names RedrivePolicy VisibilityTimeout MessageRetentionPeriodnpm run typecheck
npm run lint
npm run test
npm run test:integrationPlanner SQS LocalStack integration test is optional and gated:
RUN_INTEGRATION=true RUN_AWS_INTEGRATION=true npm run test src/tests/sqs-delivery-queue.integration.test.ts
RUN_INTEGRATION=true RUN_AWS_INTEGRATION=true npm run test src/tests/dlq-redrive.integration.test.tsnpm run dev:api
npm run dev:projector
npm run dev:planner
npm run dev:workerdev:projector projects user change events into occurrence rows.
dev:planner is enqueue-only and polls for due/missed projected occurrences.
dev:worker is implemented and polls SQS for delivery jobs.
- API writes users (
POST,PATCH,DELETE). - DB trigger inserts rows into
user_change_events. - Projector consumes unprocessed
user_change_eventsand upsertsnotification_occurrences. - Planner claims due occurrences and enqueues them to SQS.
- Worker consumes SQS messages, sends outbound HTTP request, and updates occurrence status.
After starting dev:api, dev:projector, dev:planner, and dev:worker, run:
npm run e2e:add-usersWhat the script does:
- creates two cohorts of users:
ExactScenario-*(same birthday, same timezone, same due timestamp)LookbackScenario-*(missed window recovered via planner lookback)
- creates and deletes
DeletedScenario-*, then verifies no notification occurrence is created/sent for that user - prints initial DB rows for those users
- waits for planner/worker processing
- prints final DB rows with
status,due_at_utc,sent_at,last_error - exits with error if non-deleted rows are not
sentor deleted-user row gets an occurrence
At the end, it asks you to verify webhook requests manually. Expected webhook requests:
6total (3exact-scenario +3lookback-scenario)- message format:
Hey, {full_name} it’s your birthday
Migration commands:
npm run db:migrate
npm run db:migrate:downPOST /userPATCH /userDELETE /user
Request body:
{
"firstName": "Derar",
"lastName": "Alkhateeb",
"birthday": "1990-03-07",
"timezone": "Australia/Melbourne"
}Response 201:
{
"id": "uuid",
"firstName": "Derar",
"lastName": "Alkhateeb",
"birthday": "1990-03-07",
"timezone": "Australia/Melbourne"
}Request body:
{
"id": "uuid"
}Responses:
204when deleted404when user does not exist
Request body:
{
"id": "uuid",
"birthday": "1990-03-08",
"timezone": "UTC"
}Rules:
idis required- at least one updatable field is required
birthdayandtimezonevalidation is the same asPOST /user
Responses:
200with updated user400invalid payload404user not found