Multi-tenant support desk demonstrating production-grade Next.js engineering: role-based access control, a finite-state ticket workflow, cross-tenant isolation enforced at every database query, and an immutable audit trail on every status change.
awano.chairulakmal.com/login?team=demo
Log in as support@awano.demo / oretachinomachida to see the agent desk, or use any account from
the demo accounts table below to explore a different role.
| Layer | Choice | Why |
|---|---|---|
| Framework | Next.js 16 — App Router, Server Components, Server Actions | Colocate mutations with UI; no separate API layer needed |
| Language | TypeScript strict: true |
Compiler catches missing teamId filters before runtime |
| ORM | Prisma 7 → PostgreSQL | Typed query results; compound indexes on (teamId, status) |
| Auth | Auth.js v5 — Credentials, stateless JWT, httpOnly cookie |
No session table; CSRF handled; cookie inaccessible to JS |
| Validation | Zod on every server boundary | Validated types flow through the rest of the function |
| Testing | Vitest (unit) · Playwright (E2E) | Vitest mocks Prisma — tests focus on business logic; Playwright runs full user journeys against Railway |
| Deployment | Railway — persistent container + managed PostgreSQL | No cold starts; app and DB on one platform |
Finite state machine for ticket status. All valid transitions (OPEN → IN_PROGRESS,
IN_PROGRESS → ESCALATED, etc.) and their minimum required role live in one lookup table in
src/lib/tickets/fsm.ts. assertTransition() throws on invalid pairs or insufficient role; on
success a StatusEvent row is appended for an immutable audit trail.
Atomicity on the audit trail. Each status transition writes two records — the updated ticket and
a new StatusEvent — inside a single Prisma $transaction. If either write fails both roll back,
so a ticket can never appear to jump states with no recorded cause.
Authorization as typed assertions. Business rules live in explicit typed functions
(assertAuthenticated, assertRole, assertSameTeam, assertCanViewTicket) called at the top of
every server action before any DB work begins. A missing assertion is a visible gap in the code, not
a silent omission.
teamId never comes from the client. On every mutation, teamId, userId, and role are
derived from the server-side session — the Zod schema at each server action accepts only what the
client legitimately controls. This prevents privilege escalation regardless of what a client sends.
Service layer keeps business logic testable. The pattern is
Server Action → service.ts → Prisma with no Prisma calls outside the service layer. Business rules
are unit-tested in Vitest with a mocked DB client — no database or Next.js infrastructure needed.
Diagnosing a reverse-proxy auth bug in production. After deploying to Railway, every successful
login redirected back to the login form. Railway terminates TLS at its load balancer, so without
trustHost: true Auth.js couldn't resolve the real HTTPS origin from forwarded headers and fell
back to the sign-in page after every login. One-line fix; finding the root cause required
understanding how Auth.js validates redirect URLs behind a reverse proxy.
Optimistic UI for status transitions. Clicking a status button applies the change immediately
via React's useOptimistic before the server action resolves. If the action fails — for example
because the agent's role has changed since page load — the UI reverts automatically. The server
remains the source of truth.
Password policy informed by NIST, not convention. The password change form enforces a
15-character minimum and nothing else. NIST SP 800-63B states length is a stronger entropy predictor
than character-set diversity, and that complexity rules push users toward predictable substitutions
like Password1!.
Revoking a stateless JWT without a session table. After a password change the old JWT is still
technically valid. Rather than adding a passwordChangedAt column and checking it on every request,
the client calls signOut({ callbackUrl: "/login" }) from next-auth/react after the success flash
— clearing the httpOnly cookie with no schema migration needed.
Cursor-based pagination with client-side "Load more". Ticket lists fetch limit + 1 rows; if
the extra row exists there are more pages and nextCursor is set to the last returned id. The desk
page is a Server Component that renders the first page; a DeskTicketList client component appends
subsequent pages to local state via a loadMoreDeskTickets server action. Offset-based skip is
avoided because it produces incorrect results when rows are inserted or deleted between pages.
Client/server component boundary in the header. The Header component calls auth() and
renders entirely on the server with no client JS. Interactive parts (dropdown state, keyboard and
pointer handlers) are isolated in a UserMenu client component that receives only name, email,
and role as props — keeping the server component tree as large as the UX permits.
Role-aware navigation with responsive collapse. The header derives nav links from the
server-side session role (navLinksForRole) and passes them to a NavMenu client component. On
wide viewports the links render inline; on narrow viewports they collapse into a hamburger dropdown
styled identically to the UserMenu panel. Managers see Queue + All Tickets + Dashboard; Support
sees Queue; Requesters see My Tickets. The all-tickets view (/admin/tickets) adds a status
filter bar and cursor-based pagination so managers can browse history across all statuses.
Regression caught and fixed by unit tests. A PR that restricted assignee editing to managers
also silently removed the ability for support staff to self-assign — violating the spec. The
gap was surfaced by an assignTicket unit test added during a service-layer test audit. The fix
restores SPEC-compliant behaviour: Support sees a dropdown filtered to themselves only; Manager+
sees the full team list. The server-side guard was already correct; only the UI and the E2E test
needed updating.
External requesters (customers, recruiters, field agents) submit support tickets. Support agents manage the queue, reply, and leave internal notes. Managers handle escalations, closures, and team metrics. A platform super admin provisions isolated workspaces for each organisation.
Every piece of data is scoped to a Team. A support agent from Team A cannot read, write, or accidentally stumble on Team B's tickets — this is enforced in every service function, not just in the UI.
| Role | Access |
|---|---|
| Requester | Submit tickets, comment and track their own tickets only — never sees internal notes |
| Support | Team inbox, assign, status transitions, internal notes |
| Manager | Everything Support can do + escalate, close/reopen, manage users and categories |
| Admin | User invites, role changes, category CRUD within a team |
| Super | Provision teams and users across the platform; is not part of any team |
Route guards are enforced in src/proxy.ts (Next.js 16's replacement for middleware.ts):
/desk/* requires Support+, /admin/* requires Manager+, /super/* requires Super only,
/profile requires any authenticated role.
The multi-tenant boundary is enforced in the service layer, not the UI.
| Property | Implementation |
|---|---|
| Cross-tenant isolation | Every tenant-scoped query includes teamId in the WHERE clause. assertSameTeam() is called on any fetch-by-ID path as a second check. |
| Session is the authority | On every mutation, teamId, userId, and role come from the server-side JWT — never from FormData or the request body. |
| Input validation | Zod schemas at every server action boundary. Enums are validated with z.nativeEnum() so invalid status/priority values are rejected before reaching the DB. |
| Internal comments | The service layer strips isInternal: true comments from any response to a REQUESTER session — the UI cannot opt out of this. |
| Route guards | src/proxy.ts enforces role minimums at the edge: /desk/* → Support+, /admin/* → Manager+, /super/* → Super only. |
| Session eviction on password change | signOut({ callbackUrl: "/login" }) called client-side after a successful password change — clears the httpOnly cookie immediately. |
| Login rate limiting | In-process sliding-window counter: 5 attempts per 15-minute window keyed on email address. Blocks before signIn() is called — bcrypt never runs on a blocked request. |
| Password change rate limiting | Same pattern, keyed on userId. Blocks before bcrypt work begins. Reset on success. |
| Security headers | Strict-Transport-Security, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy applied to all responses via next.config.ts. |
| Attachment MIME allowlist | addAttachment rejects any MIME type not in { image/jpeg, image/png, image/webp, application/pdf } before the DB write — prevents XSS via Content-Type spoofing on the /api/attachments/[id] serve route. |
A self-audit found one defence-in-depth gap: the top-assignees query in getDashboardMetrics
fetched users by ID without an explicit teamId filter (safe in practice because the IDs came from
a team-scoped query, but an implicit dependency). The filter was added to make isolation
unconditional.
Service tests mock @/lib/db with vi.mock so no database connection is needed — the full suite
runs in CI without a Postgres service container. Playwright E2E covers complete user journeys
against the live Railway deployment.
Coverage: FSM transitions, all authorization assertion paths, and every service function across all domains — tickets, users, categories, admin metrics, teams, and attachments. Tests include role guards, cross-team isolation, Zod validation branches, business rule enforcement (assign self-only for Support, role-ceiling checks, slug generation), and computation logic (avg response time, status count zero-filling).
npm test # 230 unit tests
npm run test:watch # Re-run on file change
npm run test:coverage # V8 coverage report
npx playwright test # E2E (requires dev server or Railway URL)
npm run db:seed:tickets # Seed 60 bulk tickets for pagination testingPrerequisites: Node.js, Docker
npm install
cp .env.example .env
docker compose up -d # Start PostgreSQL
npx prisma migrate dev # Apply schema + generate client
npx prisma db seed # Seed demo teams and users
npm run dev # http://localhost:3000npm run build # Production build
npm run lint # ESLint
npm run format # Prettier
npx prisma studio # Database GUILog in at /login?team=demo — password for all
accounts: oretachinomachida
| Role | Requester type | |
|---|---|---|
customer@awano.demo |
Requester | Customer |
recruiter@awano.demo |
Requester | Recruiter |
agent@awano.demo |
Requester | Field Agent |
support@awano.demo |
Support | — |
manager@awano.demo |
Manager | — |
admin@awano.demo |
Admin | — |
Super admin: super@awano.demo at /login (no team slug —
Super users have no team).
Full engineering design doc, data model, FSM transition table, test plan, and decision log: docs/SPEC.md.
Live at awano.chairulakmal.com.
| Concern | Approach |
|---|---|
| Build | Next.js output: "standalone" — only the production closure is shipped; dev dependencies stay out of the container image |
| Static assets | Copied from .next/static into .next/standalone/.next/static at build time so the standalone server can serve them directly |
| Database | Railway managed PostgreSQL; DATABASE_URL injected automatically via a Railway service reference variable |
| Migrations | npx prisma migrate deploy runs as a preDeployCommand — the old container keeps serving until migrations succeed and the new container passes the health check |
| Networking | HOSTNAME=0.0.0.0 set in the start command so the Node server binds to all interfaces, not just localhost |
| Config | railway.json at repo root — Railpack builder, replica count, restart policy, and region pinned to asia-southeast1 |