Skip to main content

LOCAL_DEV_SETUP — notification-service

Sibling: DEPLOYMENT_TOPOLOGY · TESTING_STRATEGY · API_CONTRACTS

Strategic anchors: 10-monorepo · 02 Enterprise Architecture §15 Developer Experience

This document gets a new engineer from "git clone" to "I sent a notification to my own phone via the WhatsApp sandbox" in under 30 minutes.


1. Prerequisites

ToolVersionWhy
Node.js>= 20.11 (LTS)runtime
pnpm>= 9.0monorepo package manager
Docker Desktop / Rancher Desktoplatestlocal infra (Postgres, Redis, Pub/Sub emulator, GCS emulator)
Docker Compose v2bundledpnpm dev:infra
gcloud CLIlatestoptional — only for live-vendor sandbox tests
mkcertlatestoptional — local TLS for WS testing
direnvlatestenvironment management
psql>= 16manual DB poking
redis-clilatestoptional

Windows users: install via WSL2 (Ubuntu) for the smoothest experience; the team's CI mirrors Ubuntu 22.04.


2. Repository layout (monorepo)

melmastoon/
services/
notification-service/
src/
domain/
application/
infrastructure/ # adapters: Postgres, Pub/Sub, Memorystore, vendor SDKs
interfaces/ # REST/WS controllers, Pub/Sub subscribers, scheduler entrypoints
config/
tests/
openapi/
drizzle/
docker/
package.json
tsconfig.json
vitest.config.ts
Dockerfile
packages/
shared/event-envelope
shared/ids
shared/clock
shared/test-fixtures-notification
shared/openapi-codegen
shared/event-schemas
infra/
docker-compose.local.yml
terraform/notification-service

Per 10-monorepo rule, service code never imports another service's src/; cross-service code lives under packages/shared/.


3. First-time setup

git clone git@github.com:ghasi/melmastoon.git
cd melmastoon
direnv allow # loads .envrc with NPM_CONFIG_REGISTRY etc.
pnpm install # installs all workspace deps
pnpm -F notification-service... build:codegen # generate OpenAPI types + event schemas

Create services/notification-service/.env.local (gitignored):

NODE_ENV=development
LOG_LEVEL=debug
PORT=3007
WS_PORT=3008

# Postgres
DATABASE_URL=postgresql://melm:melm@localhost:5439/melmastoon_notification
DATABASE_RLS_ROLE=melmastoon_notification_app

# Memorystore (Redis)
REDIS_URL=redis://localhost:6389

# Pub/Sub emulator
PUBSUB_EMULATOR_HOST=localhost:8085
PUBSUB_PROJECT_ID=melmastoon-local

# GCS emulator
STORAGE_EMULATOR_HOST=http://localhost:4443
GCS_BUCKET=melmastoon-notifications-local

# Secret Manager (use file-based in dev)
SECRETS_BACKEND=file
SECRETS_FILE=./.dev-secrets.json

# AI orchestrator stub
AI_ORCHESTRATOR_URL=http://localhost:3099
AI_ORCHESTRATOR_TOKEN=local-dev-token

# Vendor stubs
SENDGRID_BASE_URL=http://localhost:4001
TWILIO_BASE_URL=http://localhost:4002
WHATSAPP_BASE_URL=http://localhost:4003
FCM_BASE_URL=http://localhost:4004

# Cross-service projection clients (run sibling services with `pnpm dev:siblings`)
RESERVATION_PROJECTION_URL=http://localhost:3001
BILLING_PROJECTION_URL=http://localhost:3002
LOCK_PROJECTION_URL=http://localhost:3009
TENANT_CONFIG_URL=http://localhost:3000
IAM_URL=http://localhost:3010

# Synthetic tenant + user
DEV_TENANT_ID=tnt_LOCAL000000000000000000
DEV_USER_ID=usr_LOCAL000000000000000000
DEV_PROPERTY_ID=ppt_LOCAL000000000000000000

4. Bring up local infra

pnpm dev:infra

This starts (infra/docker-compose.local.yml):

ServicePortNotes
postgres5439Postgres 16 with extensions; auto-migrate via notification-migrate job
redis6389redis:7-alpine
pubsub-emulator8085gcloud beta emulator
gcs-emulator (fsouza/fake-gcs-server)4443bucket melmastoon-notifications-local pre-created
mailhog8025 (UI), 1025 (SMTP)catches local sandbox SMTP for legacy adapters (rare)
sendgrid-stub4001tiny Express app under services/notification-service/docker/stubs/sendgrid
twilio-stub4002likewise
whatsapp-stub4003likewise
fcm-stub4004likewise
ai-orchestrator-stub3099returns deterministic AI-drafted bodies + provenance
jaeger16686 (UI)tracing
prometheus9090metrics
grafana3009 (UI)pre-provisioned dashboards
loki3100logs

Verify:

pnpm dev:infra:status

5. Run notification-service

pnpm -F notification-service dev

This starts the API + WS servers and all in-process workers (router, dispatchers, scheduler, outbox-relay, prober, cache-warmer) using concurrently. Hot-reload via tsx --watch.

Health:

curl http://localhost:3007/api/v1/internal/health

6. Seed data

pnpm -F notification-service seed:dev

This creates:

  • 1 tenant (tnt_LOCAL…), 1 property, 3 staff users (OWNER, FRONT_DESK, MARKETING_MANAGER), 3 guests with locales en-US, ps-AF, ur-PK.
  • Channel configs for all 6 channel kinds pointing at the local stubs.
  • Platform-global templates for the 12 most common keys, all in en-US, plus tenant override for reservation.confirmed.email in ps-AF.
  • A trigger-map import matching the platform default.

7. Send a notification end-to-end

7.1 Via API

curl -X POST http://localhost:3007/api/v1/notifications \
-H 'Authorization: Bearer dev:owner' \
-H 'X-Tenant-Id: tnt_LOCAL000000000000000000' \
-H 'Idempotency-Key: 01J4Z000000000000000000001' \
-H 'Content-Type: application/json' \
-d '{
"templateKey": "reservation.confirmed.email",
"category": "transactional",
"channel": "email",
"locale": "ps-AF",
"recipient": { "by": "guestId", "guestId": "gst_LOCAL_AHMED" },
"variables": {
"guestName": "احمد",
"reservationCode": "MELM-2026-04-001234",
"stayWindow": { "start": "2026-04-25", "end": "2026-04-28" },
"roomLabel": "Suite 304",
"balanceDue": { "amountMicro": "120000000", "currency": "USD" },
"propertyName": "هتل د کابل",
"checkInTime": "14:00",
"policyExcerptKey": "cancellation.standard"
}
}'

The local sendgrid-stub accepts the send and emits a synthetic delivered webhook back to us within 2 s. You should see:

  • a row in notifications (status='delivered'),
  • a delivery_attempt (outcome='accepted'),
  • a webhook_inbound + event applied,
  • events notification.requested.v1, notification.dispatched.v1, notification.delivered.v1 on the local Pub/Sub emulator (subscribe with gcloud pubsub subscriptions pull for inspection).

7.2 Via consumed event

pnpm -F notification-service dev:emit -- reservation.confirmed.v1 fixtures/booking_ahmed.json

This publishes a fixture event to the local emulator; the router consumes it and enqueues 2 notifications (email + sms) per the trigger map.

7.3 In-app + WS

Open tools/dev-feed-viewer.html in a browser; it connects to ws://localhost:3008/api/v1/notifications/feed/stream with a dev token and prints incoming feed events. Send any in-app notification and watch it appear.


8. Vendor sandboxes (optional, for real-vendor tests)

For send-to-real-phone tests:

direnv exec . pnpm dev:vendors:real

Reads .env.vendors.local (gitignored) with real sandbox keys (Twilio test creds, Meta WhatsApp sandbox phone number, SendGrid sandbox API key with sink). Sends are categorised system. Recipients must be on the engineer allowlist.


9. Database tooling

pnpm -F notification-service db:migrate # run pending migrations
pnpm -F notification-service db:reset # drop + re-create local DB
pnpm -F notification-service db:gen # regenerate Drizzle schema types
pnpm -F notification-service db:psql # psql shell with role melmastoon_notification_app + app.tenant_id set to DEV_TENANT_ID
pnpm -F notification-service db:partman:run # force pg_partman maintenance

10. Tests

pnpm -F notification-service test # unit + application + contract (no Docker needed)
pnpm -F notification-service test:integration # Testcontainers-backed integration
pnpm -F notification-service test:flows # end-to-end domain flows
pnpm -F notification-service test:contracts # OpenAPI + event-schema drift + webhook fixtures
pnpm -F notification-service coverage # produces coverage report

Targeted runs:

pnpm -F notification-service test src/domain/notification
pnpm -F notification-service test:integration tests/integration/flows/booking-confirmation.test.ts

11. Lint, format, typecheck

pnpm -F notification-service lint
pnpm -F notification-service format
pnpm -F notification-service typecheck

Pre-commit hooks (Husky):

  • lint-staged runs eslint --fix + prettier.
  • pii-grep scans staged files for plaintext PII patterns in log statements.
  • gitleaks scans for secrets.

12. Working with templates locally

pnpm -F notification-service templates:import path/to/templates.yaml # bulk import
pnpm -F notification-service templates:preview reservation.confirmed.email # render to terminal + open HTML in browser
pnpm -F notification-service templates:lint --tenant tnt_LOCAL… # call lint tool against the AI orchestrator stub

The preview tool supports --locale ps-AF to test RTL rendering; output is opened via xdg-open on Linux, open on macOS, Start-Process in PowerShell.


13. Observability locally


14. Useful debug recipes

GoalCommand
List unsent queued notificationspnpm -F notification-service db:psql then select id, template_key, channel, status from notifications where status in ('queued','scheduled') order by queued_at desc limit 20;
Force re-dispatchcurl -X POST localhost:3007/api/v1/notifications/<id>/resend ...
Inspect outbox lagselect count(*), min(enqueued_at) from outbox where published_at is null;
Drop suppression for my emailupdate suppressions set released_at=now(), released_by='dev' where address_hash='sha256:…';
Trigger AI fallbackset env AI_ORCHESTRATOR_FAIL=1 and restart
Replay a DLQ entrycurl -X POST localhost:3007/api/v1/internal/dlq/<id>/retry ...

15. Common gotchas

  • PowerShell quoting for curl JSON: prefer Invoke-RestMethod or wrap JSON in single quotes when in WSL.
  • Pub/Sub emulator does not support some prod features (schemas + ordering keys are simulated client-side); contract tests catch the rare differences.
  • GCS emulator signed URLs use a fake signature; do not paste them into real GCS clients.
  • Time zones: scheduler logic depends on per-tenant Asia/Kabul etc. Local dev uses your machine TZ for display but stores ISO UTC; quiet-hours tests should set TZ=UTC before running.
  • WhatsApp template approval: locally always "approved" via the stub; in staging, real approval is required before sends succeed.
  • AI personalisation: the orchestrator stub returns deterministic outputs to keep tests reproducible. Toggle AI_ORCHESTRATOR_REAL=1 to call the real orchestrator-dev for an integration check.

16. Resetting everything

pnpm dev:infra:down
docker volume rm melmastoon_postgres_data melmastoon_redis_data melmastoon_gcs_data
pnpm dev:infra
pnpm -F notification-service db:migrate && pnpm -F notification-service seed:dev

This is the safe nuclear option when state gets weird.


17. Where to look next