A small full-stack TypeScript app for a conversational SMS system:
- inbound Twilio webhook ingestion
- durable message and conversation storage
- async processing via a background worker
- outbound SMS replies through a mock or real Twilio gateway
- admin UI for browsing conversations and messages
Architecture in one line: layered TypeScript app, repository-backed durable Postgres queue, separate worker process, and SMS gateway adapter for mock or real Twilio delivery.
- Docker and Docker Compose: recommended way to run the full stack
- Node.js 24: required for local development outside Docker
- npm: required for local scripts and dependency installation
- ngrok: required only for local end-to-end Twilio webhook testing
Docker is the preferred way to run the project because it starts the web app, worker, and Postgres with the same topology used by the assessment.
docker compose up --buildThis starts:
webon port3000workerpolling the same Postgres-backed queuepostgreson port5432
Open:
http://localhost:3000
Use this only if you already have Postgres running locally.
Create a local database:
createdb lahzoCreate a local .env file:
DATABASE_URL=postgres://YOUR_USER:YOUR_PASSWORD@localhost:5432/lahzo
SMS_GATEWAY=mockIf your local Postgres accepts socket or trust authentication, the URL may not need a password:
DATABASE_URL=postgres://YOUR_USER@localhost:5432/lahzonpm install
npm run db:reset
npm run devnpm run dev starts:
next devfor the web apptsx scripts/worker.tsfor the background worker
npm run testRuns the automated test suite against a real Postgres instance. Start Postgres first:
docker compose up -d postgresnpm run db:seedCreates sample conversations and processes them through the worker flow. Useful for quickly populating the admin UI.
npm run db:resetClears conversations, messages, jobs, and worker heartbeats from Postgres. Useful before a clean manual test.
GET /api/healthreturns a readiness-style summary with database status, row counts, and timestampsGET /api/metricsreturns a fuller JSON payload with queue and message lifecycle counters
The health check also reports whether the worker heartbeat is fresh enough to consider the async processor healthy. If no heartbeat exists yet, the worker is reported as unhealthy.
POST /api/webhooks/twilioreceives incoming Twilio SMS webhook events- inbound events are stored in Postgres as conversations and messages
- processing is handled asynchronously by
scripts/worker.ts - the worker simulates a 3-15 second processing delay by default
- outbound responses are sent through the configured SMS gateway
- message status is tracked through
received,processing,queued,sending,sent, andfailed
DATABASE_URLoptional Postgres connection stringDATABASE_POOL_MAXoptional Postgres pool sizeSMS_GATEWAYoptional outbound gateway, eithermockortwilioTWILIO_ACCOUNT_SIDrequired whenSMS_GATEWAY=twilioTWILIO_AUTH_TOKENenough for simple Twilio auth withTWILIO_ACCOUNT_SIDTWILIO_API_KEY_SIDoptional API key auth alternativeTWILIO_API_KEY_SECREToptional API key auth alternativeTWILIO_MESSAGING_SERVICE_SIDoptional sender override for Twilio Messaging ServicesTWILIO_VALIDATE_SIGNATUREoptional webhook signature validation toggle. Defaults to enabled whenSMS_GATEWAY=twilioSIMULATED_DELAY_MIN_MSoptional minimum worker delaySIMULATED_DELAY_MAX_MSoptional maximum worker delayWORKER_IDoptional worker labelWORKER_CONCURRENCYoptional number of jobs a worker processes concurrently. Defaults to5
The app uses the mock SMS gateway by default. To send real outbound SMS with account SID + auth token, set:
SMS_GATEWAY=twilio
TWILIO_ACCOUNT_SID=AC...
TWILIO_AUTH_TOKEN=...TWILIO_AUTH_TOKEN is also used to validate inbound webhook signatures. Keep TWILIO_VALIDATE_SIGNATURE unset or set to true for real Twilio traffic. Set it to false only for unsigned local curl smoke tests.
API key auth is also supported:
SMS_GATEWAY=twilio
TWILIO_ACCOUNT_SID=AC...
TWILIO_API_KEY_SID=SK...
TWILIO_API_KEY_SECRET=...By default, replies are sent from the phone number that received the inbound SMS (To in the webhook). If you prefer a Twilio Messaging Service, set TWILIO_MESSAGING_SERVICE_SID.
For local end-to-end Twilio testing, expose the app with ngrok and configure the Twilio number's inbound SMS webhook. See docs/twilio-setup.md.
The admin interface is available at /. It lists conversations, shows operational metrics, and links to /conversations/[conversationId] for the full inbound/outbound message history and message statuses.
Authentication is intentionally omitted because the assessment states it is not required.
When SMS_GATEWAY=mock, the home page also shows a developer SMS simulator. When SMS_GATEWAY=twilio, that form is hidden so real Twilio testing happens through the actual SMS webhook flow: send an SMS from a phone to the Twilio number and let the worker reply.
Frontend coverage:
- view a list of conversations
- click into a conversation
- view all inbound and outbound messages
- see message status for each message
- run
web,worker, andpostgresas separate services - set
DATABASE_URLto the production Postgres connection string - keep at least one worker replica running so queued SMS jobs are processed
- scale worker replicas horizontally or increase
WORKER_CONCURRENCYwhen queue depth or job age increases - expose
/api/webhooks/twilioas the Twilio inbound SMS webhook URL - use
/api/healthfor readiness and/api/metricsfor operational visibility - store Twilio credentials as secrets, not committed env files
- set
SMS_GATEWAY=twilioonly when real outbound SMS should be sent
The Twilio integration is mocked by default through a gateway abstraction. Set SMS_GATEWAY=twilio to use the real Twilio client for outbound SMS.
Structured JSON logs are emitted for webhook ingestion and worker activity so the processing path is observable even in the assessment environment.
For a quick manual check, open the home page and look at the operational metrics panel. Pending jobs should rise briefly after a demo SMS and fall back to zero once the worker processes it.
Claimed jobs use a lease. If the worker crashes mid-processing, an expired running job can be reclaimed and retried automatically instead of remaining stuck forever.
Outbound sends use a stable idempotency key derived from the generated reply message id. In the mock Twilio gateway, retries return the same provider message id instead of creating a duplicate send. In the real Twilio gateway, the same key is sent as X-Twilio-Idempotency-Token.
The worker writes a heartbeat into Postgres while it is running, so /api/health can tell the difference between a live system and a stalled processor.
- inbound webhook returns quickly and only persists/enqueues work
- duplicate Twilio deliveries are deduplicated by
MessageSid - Twilio webhook signatures are validated when real Twilio mode is enabled
- jobs survive worker crashes through leases and retries
- outbound delivery retries are covered in tests
- admin UI shows conversations, message history, and statuses
- no authentication is implemented by design for this exercise
- health/metrics endpoints expose operational state and worker heartbeat