Case study / 01
Hotel Operations Platform
React / TypeScript / Node / SQL
A production operations system connecting room status, housekeeping, handovers, maintenance and asset workflows across three Sydney hotels.
← All projectsProperty isolation at the service boundary
Challenge
Replace fragmented paper, spreadsheet and chat-based workflows without interrupting live hotel operations.
Contribution
Led requirements, workflow design and delivery from the operational floor. Built the platform through an AI-enabled development workflow, reviewing business rules, access boundaries, tests and production behaviour.
- 104Database tables
- 594REST endpoints
- 3,434Test cases
- 60 × 7Permission keys × job roles
- 3Live properties
- 1Developer — requirements to operations
All figures counted from the codebase, September 2026.
The problem
Housekeeping ran on paper,
spreadsheets and a chat group.
A daily clean sheet, a weekly deep-clean sheet, a chat group for room-status calls, a spreadsheet for piece-rate wages and Microsoft Lists for the handover log. Nothing was queryable, nobody could prove a room had been inspected, and a newly added room type silently paid a housekeeper nothing.
The platform replaced all of it for a three-property boutique hotel group: room status, cleaning and inspection dispatch, maintenance escalation, public-area rotas, shift handover, key and lost-property ledgers — and the modules that hang off them: piece-rate payroll, points and rewards, e-signed onboarding, inventory and an owner-facing revenue digest. Every property's data is scoped separately; every screen and endpoint is gated by a 60-permission matrix across seven job roles.
Seven roles, seven surfaces
Each job got its own screen,
not a filtered version of one.
Permissions live in a single code registry, sync into the database on boot and can be re-shaped by an admin at runtime — so the business can change a role without a code change.
Housekeeper
- Task list on their own phone, tabbed by work kind with large tap targets
- Start → complete → photo evidence; minimum-photo policy enforced server-side
- Do-Not-Disturb and no-service as first-class outcomes; locked work is explained, not hidden
- Own piece-rate income, points, rewards shop and documents to sign
Houseman
- Special-task queue: deliveries, rubbish runs, deep-clean items
- Maintenance jobs they can complete themselves
- Two-way conversion between maintenance tickets and houseman tasks
- Inventory counting and stock movement from the phone
Inspector
- Queue of finished rooms with three explicit outcomes: Ready, Vacant Clean, rework
- Rework is a round on the original task, so history stays one thread and pay is not duplicated
- Scored, itemised room audits with a printable report
- Inspector authority can be granted by duty roster, not only by role
Reception
- Live room board with batch transitions and guardrails on the high-risk ones
- One merged dispatch entry point; report-triage and express-checkout queues
- Shift handover board with follow-ups, confirmation and Excel import of the legacy sheet
- Key audit ledger, lost property, night-audit reconciliation, day-use handling, contractor kiosk
Manager
- Everything reception has, plus authority to approve, assign and escalate
- Piece-rate payroll view and daily task reconciliation — the wage basis, so staff cannot adjust their own
- Approval inbox scoped to their own property
- Public-area checklists, rosters, inspector rotation, announcements
IT operations
- Account, property and room configuration
- PMS integration health: webhooks, sync runs, watermarks, anomalies
- Notification switchboard and wiki authoring
- Deliberately withheld: payroll, the irreversible “paid” step, staff identity documents
Owner
- Access-control matrix: reassign any permission to any role at runtime
- Scheduled revenue digest; team day report; public-area weekly overview
- Staff document vault with e-signature, PDF field annotator and form builder
- Staff-facing changelog: 461 entries written for the person reading it
Architecture
One write path per concept.
All SQL in one layer.
Every mutating endpoint passes three stages: authenticate, permission key, then row-level property scope asserted on the fetched row itself.
- 01Desktop SPA · 79 routes / Mobile shell · 30 routesReact 18 · TypeScript · Vite · Ant Design tokens · Zustand · TanStack Query · react-intl (zh / en)
- 02Express API · 61 route modules · 594 endpointsauthenticate (JWT) → requirePermission(key) → enforceHotelAccess(row.hotel_id)
- 0349 services · 8 schedulers · audit loggerBusiness rules, money invariants that throw, append-only ledgers
- 0445 repositories — the only SQL in the systemServices compose repositories; routes compose services; no route builds a query
- 05SQLite (better-sqlite3, WAL) · 104 tables150+ ordered idempotent migrations, integrity-checked, pre-migration snapshots
- Nginx · PM2 · nightly backup cron
- Atomic frontend swap: build to staging, then move
- Also containerised: multi-stage build, non-root, healthcheck
The hardest part
A transition is an event,
not a snapshot.
The PMS owns reservations and room condition, but it is not trustworthy as a driver of local work: versions can move backwards, absence is not deletion, and a static “vacant dirty” reading does not mean the room needs cleaning again.
- 01PMS eventOccupied → Vacant Dirty (webhook + delta poll)
- 02MirrorIdempotent UPSERT, version-guarded, never deletes
- 03ProjectionInterprets the transition; at most one checkout cycle
- 04HousekeepingTask dispatched, photos, locked until the guest has left
- 05InspectionReady · Vacant Clean · rework
- 06Write-backOne status, one room, three gates, per-property allow-list
Conflicts with work that is in progress, paid or manually created go to a human review queue instead of being overwritten. Repairing or replaying source data can never rewrite operational work.
Database design
Decisions
worth reading.
104 tables in SQLite, reached only through 45 repository modules. Every business table carries hotel_id; multi-tenancy is a column enforced by one helper.
Money is an integer number of cents
Payroll amounts were floats and a week's wage bill was a float SUM. Integer cent columns were added and backfilled; every sum runs in cents and dollars appear only at the edge.
Prices are snapshotted at completion
A completed task stores the rate that applied at that moment. Three rate classes replaced a rate-per-room-type table that let a new room type silently pay nothing.
Money invariants throw
A typed PayrollIntegrityError fails the transaction. Silently skipping is what produced “the room is done and nobody was paid”.
Separation of duties in the schema
Approving a payroll period and marking it paid are different keys; the paid key is seeded to no role, so granting it is always an explicit, visible act.
Append-only where history is the point
Audit logs, assignment history, room flow events, PMS transitions, write-back attempts, point entries. Nothing that matters is updated in place.
Time is property-local
SQLite's datetime('now') is UTC and put notifications ten hours in the past. Every write path goes through Sydney-clock helpers; tests use the same helper for “today”.
Engineering practices
Built to be operated,
not just shipped.
3,434 tests, invariant-focused
Service and repository tests over money and state transitions, route and permission-matrix tests, component and hook tests, property-based tests with fast-check. A failing test is never dismissed as flake.
Permissions as declared data
One registry file; boot-time sync; the same key gates the UI through <Can> and the API through requirePermission. Retiring a key is a first-class operation.
A real design system
Tokens from the client's brand, one PageFrame for every module page, one table language, at most one primary action per page, a separate mobile shell.
Release engineering, end to end
Nginx, PM2, atomic frontend swap, pre-migration snapshots, post-deploy verification, nightly backups with 14-day retention, and a staff-facing changelog with 461 entries.
Codebase
Where the ~260,000 lines of TypeScript live
Production code ~195k lines; tests ~64k lines across 340 files.
What this project evidences
- Full-stack TypeScript at scale
- Relational modelling & ledgers
- Schema evolution under load
- API design & layered auth
- Third-party integration
- Authorization architecture
- Mobile-first UX for field staff
- Design systems
- Test engineering
- DevOps / SRE
- Product discovery
- Solo ownership
Full metrics table
| Production TypeScript / TSX (excl. tests) | ~195,000 lines |
|---|---|
| Total TypeScript incl. tests | ~260,000 lines |
| Database tables | 104 |
| Ordered, idempotent migrations | 150+ (latest deployed #156) |
| REST endpoints | 594 across 61 route modules |
| Repository / service modules | 45 / 49 (+26 PMS integration) |
| Background schedulers | 8 |
| Permission keys / job roles | 60 across 8 modules / 7 |
| Desktop / mobile routes | 79 / 30 |
| Test files / cases | 340 / 3,434 (~64,000 lines) |
| Staff-facing changelog entries | 461 |
| Live properties | 3 |
| UI languages | 2 (English / Chinese) |
Delivery process
How the work
came together.
From the first model of the problem to checking how the system behaves.
Observe the work
Mapped real front-desk, housekeeping and maintenance handoffs before deciding what the software should automate.
Define boundaries
Converted roles, hotel scope and exception paths into explicit access and data rules.
Deliver in slices
Released workflows incrementally so staff could validate behaviour while hotel operations continued.
Protect production
Used build, lint and test gates, audit history, deployment controls and staff documentation to reduce operational risk.
Engineering evidence
The details
that matter.
- Per-property data isolation and role-based access
- Audit logging and regression coverage
- 104 tables · 594 REST endpoints · 3,434 tests (as of Sept 2026)
- 60 permission keys across 7 job roles, reassignable at runtime
- Bidirectional PMS integration: read-only mirror, projection, gated write-back
- 500+ commits behind build, lint and test gates; 150+ idempotent migrations
- Production on Linux with PM2 and Nginx, nightly backups, atomic deploys
Inside the decisions / Expand to read
01Put property boundaries in the service layer
A hotel selector is useful for navigation, but each request also needs to respect the user's permitted property. Keeping the rule at the service boundary makes it apply when an operation reaches the API outside the expected interface flow.
02Keep operational exceptions visible
Room status, housekeeping and handovers have different owners. Explicit states let staff see unfinished work and exceptions, and give the software concrete transitions to validate.
03Pair assisted implementation with review
AI tools help produce and iterate on changes. I own the requirements, business rules, integration, debugging, test review and acceptance of the result in the working environment.
04Make changes traceable
Audit history supports investigation after a change; regression tests protect known behaviour before release. Both matter when several properties depend on the same system.
Outcome
The platform moved from an individual build into live multi-property operations, replacing disconnected processes with one auditable system.
AI-enabled delivery: I own the requirements, business rules, review, integration, testing and operational rollout. This public case study uses architecture descriptions; production data and source remain private.
- React
- TypeScript
- Node.js
- Express
- SQL
- Linux
- PM2
- Nginx
Next case study
02Power Platform Internal Operations Suite→