← shawaz.org · Case study

An AI front desk for building maintenance. Sole engineer, in production.

A multi-tenant platform that answers resident calls and messages, decides how urgent each problem is, dispatches vendors, and keeps a property-management company's records in sync. This page covers the parts that were hard: isolating tenants, getting emergencies right, billing, and what broke in production. Names of the company, product, and vendors are left out on purpose.

Context

I work for a property-management company, where I run facilities as well as build its software. The platform grew from an existing codebase that I took over after licensing and security review, and reworked the data layer, voice pipeline, billing, and delivery from there. I'm the only engineer, so every decision below was mine, and so was the pager when one of them was wrong.

Architecture

A TypeScript monorepo (NestJS API, workers, and realtime services; Next.js dashboard; Expo mobile app) and a Python voice service, sharing one PostgreSQL database and a Redis-backed job queue.

Residents reach the platform by phone, SMS, email, or web. Calls go to a Python voice service, which hands off to the NestJS API along with every other channel. The API enqueues jobs on Redis for workers that dispatch, sync, and notify. All services read and write a PostgreSQL database under row-level security. External systems are telephony carriers, LLM providers, Stripe, and the customer's property-management system. Phonemedia streams SMS · Emailsigned webhooks Web · MobileNext.js · Expo Voice servicePython · STT → LLM → TTS APINestJS · auth · triage Job queueRedis · BullMQ Workersdispatch · sync · notify PostgreSQLforced row-level security · unprivileged runtime role AI & telephonyLLMs · carriers Billing & recordsStripeproperty-mgmt API

1 · Tenant isolation lives in the database

Every organisation's data shares one database, so a single missing WHERE org_id = … in application code could show one customer another customer's residents. I moved that guarantee into Postgres: forced row-level security on every org-scoped table (78 tables in the schema), with policies keyed on a per-request org context.

Three traps came up on the way, and each one would have quietly switched isolation off:

  • Superusers bypass RLS. Policies pass every test if the app connects as the role that owns the tables. The app now runs as an unprivileged runtime role, and migrations run as a separate owner.
  • Session settings leak through a pool. A plain SET of the org context stays on the connection and carries into the next request that borrows it. Context is set with SET LOCAL inside the request's transaction, so it ends with the transaction.
  • Connections must start from a known state. The pool's default context is sent in each connection's startup parameters, so a connection never begins with whatever a previous session left behind.

Cross-org attack tests, where org A tries to read and write org B's rows through every path, run in CI.

2 · Getting emergencies right

Resident messages are triaged by an LLM, but an LLM's urgency score can't be the last word. A no-heat call in January in New England is an emergency whatever the model says, and state rules set minimum heat levels during the heating season.

The rule is a floor, not a suggestion: life-safety keywords and seasonal no-heat reports lift the priority to a minimum that org configuration can't lower. I rejected two alternatives. Marking these "high" and relying on the call script wasn't enforced anywhere. Adding a new "urgent" tier would have left every downstream consumer to handle a level it didn't know about.

When a vendor can't be reached, a dispatch cascade tries the next one and finally pages on-call staff. One principle shapes all of the messaging:

Never let a message claim someone was reached when they weren't.

3 · Billing that survives the real Stripe

  • Out-of-order events. Stripe doesn't guarantee delivery order, so an older subscription event can arrive after a newer one. Each update carries the event timestamp in its WHERE clause and applies only if it's newer than the stored one.
  • Idempotency keys come from the request. The key is a hash of the request parameters, not a random value per attempt, so a retry can't create a second charge.
  • Contract tests against the sandbox. A live suite runs against Stripe's test mode. It caught three defects that the mocked unit tests passed, because a mock accepts parameters the real API rejects.

4 · Writing to a vendor API that says "OK" to everything

The customer's property-management system exposes an API that returns 200 even when it silently drops fields it doesn't recognise. A misspelled field looks like success.

  • I probed it with a deliberately fake field. It came back 200, which proved a success status meant nothing on its own.
  • Every write is now read back and compared field by field, and retried until what the API echoes back matches what was sent.
  • Writes that change records the customer relies on go through a human approval step before they execute.

5 · Incidents and what they changed

Calls dropped at a fixed time

Live calls were being cut off at the same point every time. The cause was the telephony markup's pause length: when the pause ran out, the carrier ended the call. I replaced the pause with a redirect loop that holds the call open, and added a harness scenario that stays on the line past the old limit. That harness places real calls, so the test fails the way a real resident would experience it.

398 green tests, and the app couldn't start

The test suite passed, but the API wouldn't boot. The build strips types without checking them, and the tests mocked out the parts that failed at startup. I added a required smoke-boot job that starts the real services and checks their health. On its first run it found two bugs.

A deploy step that always passed

One CI deploy step turned out to be a placeholder that reported success without doing anything. It now performs the deploy and fails if the deploy fails.

One space in a config file took services down

An unquoted environment value containing a space broke service startup. Deploys now run a precondition check that parses the environment the same way the services do, and stop before anything restarts.

What I'd change

  • CI builds container images that nothing deploys. They cost minutes on every run and should go.
  • Some of these checks came after an incident rather than before it. The smoke-boot job and the env precondition check should have existed on day one.

Stack

TypeScript, NestJS, Next.js, Expo, PostgreSQL (Drizzle, 122 migrations), Redis/BullMQ, Python and Pipecat for voice, Twilio and Telnyx, Stripe, Terraform on GCP, GitHub Actions, and Playwright. The UI ships in 9 languages, including right-to-left.

Happy to go deeper in an interview: shawaz@shawaz.org →