M
Back to client projects
clientInnovative WidgetApr 2026 – Jul 2026 (ongoing)
featured

Lexnis Residential

Production backend for a residential security SaaS — async FastAPI + WebSocket server with real-time SOS emergency system, shift management, area group chat, AWS S3 CSV exports, and fully automated Docker CI/CD pipeline on AWS EC2 with health-check rollback.

I own the backend solo: the FastAPI + SQLModel service in Server/, 17 SQLModel tables, 15 Alembic migrations, ~165 REST + WebSocket endpoints across auth, admin, customer, officer, chat and notification routers, plus the real-time layer (WebSocket connection manager) and the Firebase push integration. I designed the schema, the SOS lifecycle state machine, and the concurrency-safe reference-number scheme. I did not build the Flutter mobile app or the Next.js admin panel — those are separate repos this backend serves.

// the problem

What was broken

A residential estate needs a way for residents to raise an emergency and have an on-duty guard actually respond, with a record of what happened afterwards. Before this, the "system" was a phone call or a WhatsApp group — no way to know which guard was assigned, whether they were on their way, when they arrived, or what the incident actually was. Nothing was logged, so there was no accountability trail when a resident asked "what happened to my alert last Tuesday?" Guards, residents and the estate admin were all working off memory and screenshots.

// constraints

What made it hard

Three clients consume one backend — a customer app, an officer app (both Flutter), and an admin panel (Next.js) — so the API had to be the single source of truth and every state change had to reach all three in real time. The target host is serverless (NeonDB Postgres over asyncpg), which rules out anything that assumes a long-lived process owns the database. Guards and residents are on phones with flaky connectivity, so "real-time" could not mean "only works while the socket is open." And this is an emergency product — a dropped or ambiguous SOS is a safety failure, not a bug ticket.

// architecture

How it fits together

The service is a thin-route / fat-controller FastAPI app: routers do auth, validation and HTTP status; all business logic lives in controllers (officer_controller, customer_controller, admin_controller, …). Everything is async end to end against Postgres via asyncpg, because the same request that writes an SOS also fans it out to live sockets and fires a push — blocking I/O anywhere in that path stalls the event loop.

The two decisions that shaped everything: one unified messages table and a dual real-time delivery path. Rather than separate tables for group chat, resident↔admin queries, SOS channels and internal admin chat, there is a single messages table keyed by channel_type + channel_id. That means one query path, one media pipeline, one pagination implementation for every kind of conversation — at the cost of the table meaning nothing without its discriminator, which I accepted. For delivery, live updates go over an in-process WebSocket ConnectionManager (an asyncio.Lock-guarded dict of channel → sockets), and the *same* events also go out as Firebase Cloud Messaging pushes. WebSocket handles "resident is staring at the screen"; FCM handles "resident's phone is in their pocket." The push path is deliberately fire-and-forget — it logs and swallows every failure and never raises, because a Firebase outage must not block an SOS from being written.

Media (chat images, incident attachments) is stored in S3 by key, not by URL: the DB holds media_key and a presigned URL is generated fresh on every read with a 1-hour expiry, so no long-lived public links leak.

Client

Flutter customer app POSTs /customer/sos; officer & admin apps hold WebSockets open

API layer

FastAPI /api/v1 — JWT auth dependency, role guard, slowapi rate limit, request validation

Controller

SOS state machine (SENT→RESPONDED→PENDING_APPROVAL→RESOLVED) + incident-report creation as side effects

ConnectionManager

In-memory channel fan-out — broadcasts to sos:{area_id}, sos:admin, notifications:{customer_id}

FCM

firebase-admin push to the same recipients for backgrounded/offline devices, fire-and-forget

Data store

NeonDB Postgres via asyncpg — sos_alerts row, sos_incident_reports, atomic reference_counters

S3

incident/chat media by key; presigned GET URL minted per read

// challenges

Where I got stuck

The SOS lifecycle is a state machine with a human in the middle

An alert moves SENT → RESPONDED (officer acknowledges) → officer sets ETA → marks arrived → resolves. Resolving does *not* close it: it writes an incident report and moves the alert to PENDING_APPROVAL, then the *resident* approves or rejects the resolution, with a consent/rejection branch when they disagree. Each transition guards its predecessor (you cannot mark arrived before an ETA is set, cannot resolve before arriving) and each fans out its own WebSocket event and push. The hard part was that "resolved" is the officer's claim, not the truth — the resident has the final say — so the model carries separate resolved_at, customer_approved_at, rejected_at, rejection_reason and resolution_consent columns instead of a single boolean.

Postgres native enums fought SQLModel binding

SQLModel created the DB enum types from the Python enum *member names* (uppercase, SOS_CSV), but str-enum query binding sends the *values* (lowercase, sos_csv) — so every filter on those columns silently mismatched. Adding a new status was worse: ALTER TYPE … ADD VALUE cannot run inside a transaction, and Postgres cannot remove an enum value, so the migration (004) is a one-way ADD VALUE IF NOT EXISTS with a no-op downgrade. I eventually gave up on native enums for the export type and migrated the column to plain VARCHAR with a USING LOWER(...) cast (015). Lesson learned the expensive way: with SQLModel + Postgres, string columns beat native enums.

Reference numbers had to be unique under concurrency

Every SOS, incident and export needs a human-readable PREFIX-YYYYMMDD-NNNN id that two simultaneous requests can't collide on. A SELECT max()+1 would race. I used a reference_counters(prefix, counter_date) table and a single atomic statement — INSERT … ON CONFLICT (prefix, counter_date) DO UPDATE SET last_seq = last_seq + 1 RETURNING last_seq — so the database does the increment and hands back the sequence in one round trip, with no application-level lock.

Real-time presence and history over one socket

The chat WebSocket has to authenticate a JWT passed as a query param, verify the connecting user actually belongs to that area group (or has an active shift, for officers), send the last 50 messages as history on connect, then serve cursor-based load_history pages on demand — all while broadcasting new messages, an area-list metadata update, and an FCM topic push per message. Getting the media URLs right meant regenerating a presigned URL for every historical message on the way out, since stored keys outlive any single signed link.

Startup can't assume the database is reachable

On a serverless target the app may boot before the DB is warm. The lifespan handler seeds the admin account and app-version row inside a try/except that logs and continues, flipping an app.state.database_ready flag that /health reports as ok vs degraded, rather than crash-looping the container on a cold database.

// new ground

What I hadn't used before

Firebase Cloud Messaging (firebase-admin)First time wiring server-side push. I learned it is not one API but several — multicast to device tokens, topic broadcast, single-token — each needing explicit Android priority and full APNS payload config or iOS silently drops the notification when backgrounded. I also learned to treat invalid/unregistered token codes as a signal to prune dead tokens rather than retry them.
WebSockets as a first-class channel, not an afterthoughtI had used request/response APIs; running a channel manager with per-channel subscriber lists, join-multiple-channels-per-socket, typing/read events and dead-socket cleanup under an async lock was new. I understand now why presence and history are the hard parts, not the message plumbing.
Alembic enum surgeryBefore this I treated migrations as add-a-column. This project taught me the operations Postgres won't do transactionally or reversibly, and how to write a defensible one-way migration with a documented no-op downgrade.
Async S3 with boto3boto3 is synchronous, so every call is wrapped in asyncio.to_thread to keep it off the event loop, and the client is built lazily and cached — with a 503 if storage credentials aren't configured rather than a crash at import.

// takeaways

What I took away

  • Modeling "resolved" as an officer claim that a resident must confirm, rather than a single status flag, is what turned the SOS flow from a feature into an accountability record.
  • Native Postgres enums are a liability with SQLModel; a VARCHAR plus an application-level enum avoids a whole class of name-vs-value binding bugs.
  • Real-time delivery needs two paths — a socket for the foreground and a push for the background — because "the connection is open" is never a safe assumption on mobile.
  • Push notifications fail constantly (dead tokens, provider hiccups) and must be fire-and-forget; letting FCM raise into the request path would have made a Firebase outage look like an SOS outage.
  • Concurrency-safe sequencing belongs in the database (ON CONFLICT … RETURNING), not in Python where two requests will eventually race.

// the journey

How it actually went

It started as a straightforward CRUD backend — users, areas, shifts, a chat table — and the SOS was going to be "insert a row, notify a guard." That lasted until the first real conversation about accountability, when it became obvious that an alert without a lifecycle is just a louder phone call. The migration history is honest about this: the very first migration adds ETA/arrived/resolved timing columns to a table that already existed, because I hadn't seen the state machine coming.

What I got wrong early was trusting the ORM's defaults. I let SQLModel generate native Postgres enums and moved on, and it worked in every test until a filtered query returned nothing in a real database and I couldn't see why. The DB had SOS_CSV, the query sent sos_csv, and nothing errored — it just quietly matched zero rows. Tracking that down, and then discovering Postgres won't let you remove an enum value or add one inside a transaction, was the week that taught me to stop reaching for native enums.

The moment it clicked was the customer-approval step. Once I stopped treating "resolved" as a boolean the officer flips and modeled it as a claim the resident confirms — with its own rejection and consent branches — the whole thing made sense. The rating system, the incident report, the reference numbers all hung off that spine cleanly, because there was finally a real object with a real end state to attach them to.

If I did it again I'd design the SOS state machine and the reference-number scheme on day one instead of retrofitting them across a dozen migrations, and I'd use VARCHAR for status columns from the start. I'd also decide the WebSocket-plus-FCM dual delivery contract up front rather than discovering per-feature that a socket broadcast alone never reaches a backgrounded phone.

The one thing I'd keep exactly as-is is the fire-and-forget push layer. Every time Firebase misbehaved in development, the SOS still wrote, still broadcast, still health-checked green — and that separation is the difference between a degraded feature and a down product.

// outcome

What it changed

Shipped as the live backend for the Lexins platform, serving a Flutter customer app, a Flutter officer app and a Next.js admin panel from one API. What's in the repo: 17 tables, 15 Alembic migrations, ~165 REST and WebSocket endpoints, a full SOS lifecycle from raise through officer response to resident-confirmed resolution with an attached incident report, unified real-time chat, and dual WebSocket + FCM delivery. It is backed by 730 test functions across 42 test modules covering auth, admin analytics, the SOS flow, ratings, notifications and media handling. As of July 2026 the client-feedback round (daily occurrence logs, officer-initiated SOS, map tracking, multi-officer dispatch) is planned and in progress.

// overview

In more detail

Lexins SOS is a comprehensive community management system for residential estates. The backend is a fully async FastAPI (Python 3.13) + WebSocket server with 60+ REST endpoints and 3 WebSocket channels, serving Admin, Customer, and Security Officer roles via JWT auth with refresh token rotation.

The SOS emergency system broadcasts real-time alerts to active officers via WebSocket and tracks the full lifecycle: SENT → RESPONDED → PENDING_APPROVAL → RESOLVED with ETA, arrival time, resolution time, and a post-SOS officer rating. AWS S3 CSV exports use boto3 presigned URLs with a graceful BYTEA fallback. The CI/CD pipeline runs: ruff lint → pytest (200+ tests, Postgres 16 container) → docker buildx → push to AWS ECR → SSH to EC2 → migrate → health-check rollback.

// features

What it does

  • Real-time SOS emergency system — WebSocket broadcasting, full lifecycle (SENT → RESPONDED → PENDING_APPROVAL → RESOLVED)
  • 3 WebSocket channels: area group chat, SOS alert channel, admin DM queries
  • Shift scheduling with auto-add/remove of officers to area group chats on shift lifecycle events
  • Service request lifecycle (PENDING → IN_PROGRESS → COMPLETED / CANCELLED) with soft deletes
  • AWS S3 CSV exports via boto3 presigned URLs (1-hour expiry) with BYTEA fallback
  • SOS analytics — trend charts, area-wise breakdown, response-time distribution, KPI cards
  • Refresh token rotation — every refresh issues a new token and revokes the old one
  • Admin seeding on startup from env vars — no manual DB insertion needed
  • 200+ async integration tests (pytest + httpx) with Postgres 16 service container in CI

// stack

Built with

FastAPI
Python 3.13
SQLModel
SQLAlchemy (async)
asyncpg
PostgreSQL (Neon)
WebSocket
JWT
bcrypt
boto3
AWS S3
AWS EC2
AWS ECR
AWS IAM
Docker
GitHub Actions
Firebase FCM
pytest
Uvicorn
Questions about this build? Get in touch