24 KiB
Field Service Work Order Management — Technical Specification
Version: 1.2 Status: Implementation contract Last Updated: 2026-08-16
Incorporates findings from two adversarial reviews and two design reviews of v1.0 Draft. v1.2 rebrands the generic item model as work orders for field service and maintenance teams.
1. Overview
1.1 Purpose
A production-oriented MERN (MongoDB, Express, React, Node.js) application purpose-built as a work-order management system for field service and maintenance teams. Technicians log jobs, track them through status and priority, and close them out; a dispatcher (admin) oversees every work order and the team. It keeps the production-grade baseline — layered architecture, cookie-based auth, typed data layer — and is not the smallest possible todo app.
1.2 Goals
- Working, deployable app out of the box (seeded admin + user).
- Domain-shaped work-order model: status/priority lifecycle instead of generic items.
- Layered backend and feature-sliced frontend.
- JWT in httpOnly cookies, refresh rotation with family reuse detection.
- Typed frontend data layer (wrapped
fetch+ TanStack Query). - Unit + integration tests with coverage gates.
- Reproducible local + Docker setup.
- Structured logging, validation, and a closed error catalog.
1.3 Non-Goals (v1)
- Real-time (WebSockets).
- Multi-tenant / org isolation.
- Payments.
- File uploads / object storage (no stub interface).
- Internationalization (en-US only).
- Audit-log product UI (removed from admin persona).
- Work-order reassignment or scheduling: the creator owns a work order in v1 (dispatcher reassignment is a v2 concern).
- Multi-document Mongo transactions (standalone Mongo).
- CSRF tokens (same-origin deployment; see §8.5).
1.4 Personas
| Persona | Role | Primary needs |
|---|---|---|
| Dispatcher (admin) | Full access | Manage users and roles, oversee and search all work orders, view metrics |
| Technician (user) | Standard user | Log, update, and close own work orders; manage profile |
| Anonymous visitor | Unauthenticated | Register, login, request/complete password reset |
2. Key Decisions
| Decision | Options considered | Choice | Why |
|---|---|---|---|
| Auth transport | Bearer in memory vs httpOnly cookies | httpOnly cookies + SameSite=Lax | XSS cannot read tokens; see ADR-0001 |
| Deploy topology | Split origins vs reverse-proxy same origin | Same origin: Vite proxy (dev), nginx (prod/compose) | Lax cookies work; CSRF tokens out of scope |
| HTTP client | fetch vs axios | Wrapped fetch |
No extra dep; tests mock fetch |
| Lockfile | npm vs yarn | package-lock.json only |
npm workspaces default |
| Monorepo | workspaces vs turborepo | npm workspaces | Simpler for two packages + packages/shared |
| Schemas | duplicate vs shared package | packages/shared zod + types |
Prevents envelope/DTO drift |
| Pagination | cursor everywhere vs mixed | Cursor on /work-orders and /admin/work-orders; offset on /admin/users |
Users table is small; work orders follow NFR-2 |
| Admin all work orders | scope= vs dedicated route |
No scope. /work-orders is owner-only. /admin/work-orders is global |
Removes list IDOR |
| Refresh storage | embed on User vs collection | RefreshSession collection + familyId |
Multi-device + reuse ⇒ revoke family |
| Refresh signing | opaque vs JWT | Opaque 32-byte, SHA-256 at rest | Drop REFRESH_SECRET |
| First admin | first-register vs seed | Idempotent npm run seed |
Deterministic local/CI credentials |
| User disable | isActive vs deletedAt vs both |
isActive only on User |
One state machine |
| Work order delete | hard vs soft | Soft deletedAt; GET 404; lists exclude |
Matches WO-7 |
| Mongo txns | replica set vs none | None in v1 | Compose Mongo is standalone |
| Perf SLO | p95 < 200 ms @ 1k conc. | Dropped from v1 DoD | bcrypt 12 cannot meet it; untestable |
| Register enumeration | hide vs 409 | 409 EMAIL_TAKEN |
Starter documents this leak |
| Lockout status | 423 vs 429 vs generic 401 | Generic 401 AUTH_GENERIC |
Avoids lockout oracle |
| HTTP access JWT role | trust JWT role vs DB | Load user every request; authorize from DB | Demotion/deactivation take effect immediately |
ADRs: docs/adr/0001-jwt-httpOnly-cookies.md, docs/adr/0002-npm-workspaces.md.
3. Tech Stack
| Layer | Technology | Version |
|---|---|---|
| Runtime | Node.js | 20 LTS |
| Language | TypeScript | 5.x |
| API | Express | 4.x |
| Database | MongoDB | 7.x |
| ODM | Mongoose | 8.x |
| Frontend | React | 18.x |
| Build | Vite | 5.x |
| Routing | React Router | 6.x |
| Server state | TanStack Query | 5.x |
| HTTP | wrapped fetch |
— |
| CSS | Tailwind CSS | 3.x |
| Validation | zod (in packages/shared) |
3.x |
| Auth | JWT HS256 + httpOnly cookies | — |
| Passwords | bcrypt cost 12 | 5.x |
| Tests | Vitest + Supertest + Testing Library | 2.x / 7.x / 14.x |
| Logs | pino | 9.x |
| Containers | Docker + compose | 24.x / 2.x |
| Lint / format | ESLint 9 flat + Prettier 3 |
Pin exact versions in the root package-lock.json. Do not commit yarn.lock.
4. Architecture
Browser (one origin)
Vite :5173 ──proxy /api,/health,/ready──► Express :4000 ──► MongoDB :27017
nginx :80 ──same paths──────────────────► Express :4000 ──► MongoDB
4.1 Backend dependency direction
routes → middleware → controllers → services → repositories → models
- Controllers map HTTP ↔ DTO. No business rules, no Mongoose.
- Services take an
Actor { id, role }(neverreq/res). - Repositories own queries. Default work-order reads add
deletedAt: null. - Models export schemas + TS types. Only repositories import
mongooseat runtime. import/no-cycleis required. v1 does not use multi-document transactions.
4.2 Middleware order (app.ts)
trust proxyfromTRUST_PROXY_HOPS(default 0)- request-id (
X-Request-Id, generate UUIDv4 if missing/invalid) - pino-http (redact cookies/passwords/hashes)
- helmet (API JSON CSP:
default-src 'none') - compression
- cors (
origin=CORS_ORIGINlist,credentials: true; reject*if credentials) - cookie-parser (
COOKIE_SECRET) - json (
limit: 32kb) — nourlencoded(blocks HTML-form CSRF) - hpp
- recursive
$/__proto__/constructorrejection on body + query - global rate limit
- routes
- 404 handler
- error middleware
4.3 Frontend layering
pages → features/{auth,workOrders,admin,profile} → components (primitives) → api/client → lib
- Query hooks live in
features/*/queries.ts, not inapi/. - React Router loaders are not used. TanStack Query owns server state.
ProtectedRoute/AdminRouteare UX only; API is the authority.- Bootstrap:
GET /users/me; 401 → anonymous. 401 interceptor single-flightsPOST /auth/refreshthen retries once.
5. Functional Requirements
5.1 Authentication & authorization
| ID | Requirement |
|---|---|
| AUTH-1 | Register { email, password, name }. Email trim+lowercase. Name 1–80 chars. Password 8–72 chars, ≥1 letter, ≥1 number. Default role=user, isActive=true. Duplicate email → 409 EMAIL_TAKEN. Success 201 + set session cookies (auto-login). |
| AUTH-2 | Login { email, password }. Unknown email, bad password, inactive user → 401 AUTH_GENERIC after dummy bcrypt. Success 200 + cookies, set lastLoginAt, reset failedLoginCount. |
| AUTH-3 | Access cookie access_token: JWT HS256, claims sub, role, sid, iss, aud, iat, exp (15m). httpOnly, SameSite=Lax, Secure iff NODE_ENV=production, Path=/, Max-Age=900. |
| AUTH-4 | Refresh cookie refresh_token: opaque 32 bytes, SHA-256 stored on RefreshSession, 7-day TTL, Path=/api/v1/auth, same flags as access, Max-Age=604800. Rotation keeps familyId. Reuse of a used/revoked token outside a 10s grace window revokes the family and returns 401 REFRESH_REUSE. Multi-device = many families. |
| AUTH-5 | authenticate verifies JWT, loads user by sub, rejects missing/inactive, attaches Actor from DB (id, role). Invalid ObjectId params → 400 VALIDATION_ERROR. |
| AUTH-6 | POST /auth/logout revokes the current session (sid or presented refresh). Clears both cookies (Max-Age=0). POST /auth/logout-all revokes every family for the user. |
| AUTH-7 | Forgot-password always 200 + generic message. Token: 32 random bytes, SHA-256 at rest, TTL 1h, replaces previous. MVP mailer logs a redacted URL (token replaced with [redacted]). Reset page /reset-password?token=. Successful reset revokes all families, clears reset fields, sets cookies. |
| AUTH-8 | Profile name via PROFILE-2. Password change via PROFILE-3 (same password rules). |
| AUTH-9 | Roles admin | user. Only admin can change roles. Cannot target self. Cannot demote the last remaining admin (count admins, reject). Reload DB role (AUTH-5). Demotion revokes that user’s refresh families. |
| AUTH-10 | Owner/ids come from Actor, never from the body. Write schemas .strict(). |
| AUTH-11 | After 5 failed logins for an existing user within 15 minutes, set lockedUntil = now+15m. Unknown emails do not increment. Reset counter on success. Locked users still receive 401 AUTH_GENERIC. IP limiter is separate (429 RATE_LIMITED). |
5.2 Work orders
A work order is a job logged by a technician and tracked from request to completion. The creator is the owner; dispatchers see everything via /admin/work-orders.
| ID | Requirement |
|---|---|
| WO-1 | Fields: title (job summary) 3–100, description optional ≤2000 (job details/notes), priority low|medium|high (default medium), status pending|in_progress|done (default pending), owner, version (starts at 1), createdAt, updatedAt, deletedAt?. |
| WO-2 | GET /work-orders returns only owner = actor.id. No scope param. |
| WO-3 | Cursor pagination: default limit=20, max 100. Sort createdAt desc, _id desc. Cursor = HMAC-SHA256(payload, COOKIE_SECRET) + payload { createdAt, id }. Tamper → 400. Last page: nextCursor: null. |
| WO-4 | Filter status and priority. Search title regex-escaped, case-insensitive, max 64 chars. |
| WO-5 | Create → 201 full WorkOrderPublic. version=1. Owner = actor. |
| WO-6 | Update owner or admin. version required. Stale → 409 CONFLICT_VERSION. Predicate { _id, version, deletedAt: null } then $inc.version. |
| WO-7 | Soft delete owner or admin. version required. 204 empty body. Already deleted → 204 (idempotent). GET-by-id and lists hide deletedAt != null. No undelete. |
| WO-8 | Zod .strict() on writes before services. |
| WO-9 | See WO-6/7. Create version=1. Custom field, mongoose versionKey: false. |
| WO-10 | GET /work-orders/:id owner or admin; anyone else or deleted → 404. Invalid id → 400. |
5.3 Admin
| ID | Requirement |
|---|---|
| ADM-1 | GET /admin/users admin only. Offset page, default 1, limit 20 max 100. Search name+email, escaped, max 64. Filter role. |
| ADM-2 | PATCH /admin/users/:id/role { role } strict enum. Not self. Not last admin. |
| ADM-3 | GET /admin/work-orders all non-deleted work orders, cursor pagination. |
| ADM-4 | GET /health public { status: "ok" }, no internals, excluded from rate limit. GET /ready public, pings Mongo, 503 if down. GET /admin/metrics admin { users, workOrders, uptimeSeconds }. |
| ADM-5 | PATCH /admin/users/:id/status { isActive }. Not self. Deactivate revokes that user’s families. |
5.4 Profile
| ID | Requirement |
|---|---|
| PROFILE-1 | GET /users/me → UserPublic. |
| PROFILE-2 | PATCH /users/me { name } only. |
| PROFILE-3 | POST /users/me/password { currentPassword, newPassword }. Wrong current → 401 AUTH_GENERIC. Success revokes other families, rotates current session, sets cookies. |
5.5 Frontend routes
| Route | Access | Notes |
|---|---|---|
/ |
Public | Landing |
/login, /register |
Public | Redirect to /app if already authed |
/forgot-password |
Public | Request form |
/reset-password |
Public | Token from ?token= |
/app |
Protected | Redirects to /app/work-orders |
/app/work-orders |
Protected | List + filter (status/priority) + search + cursor |
/app/work-orders/new |
Protected | Create |
/app/work-orders/:id |
Protected | Detail / edit / delete; 409 reload |
/app/profile |
Protected | Name + password |
/app/admin |
Admin | Users, role, status |
* |
— | 404 page |
UI gates are not authorization.
6. API Contract
Public (unversioned): GET /health, GET /ready
App base: /api/v1
Auth cookies: access_token, refresh_token (see AUTH-3/4).
6.1 Envelopes
type SuccessEnvelope<T> = { success: true; data: T };
type ErrorEnvelope = {
success: false;
error: { code: ErrorCode; message: string; details?: { field: string; message: string }[] };
requestId: string;
};
type ErrorCode =
| 'VALIDATION_ERROR' | 'UNAUTHORIZED' | 'FORBIDDEN' | 'NOT_FOUND'
| 'CONFLICT_VERSION' | 'RATE_LIMITED' | 'ACCOUNT_LOCKED'
| 'AUTH_GENERIC' | 'EMAIL_TAKEN' | 'REFRESH_REUSE' | 'INTERNAL';
204 has no body. Auth failures that must not distinguish cause use AUTH_GENERIC + 401. ACCOUNT_LOCKED is reserved but unused on the wire in v1 (lockout still enforced).
6.2 Status map
| HTTP | Code | When |
|---|---|---|
| 200/201 | — | Success envelope |
| 204 | — | Soft delete |
| 400 | VALIDATION_ERROR | Zod, bad ObjectId, bad cursor, search too long |
| 401 | UNAUTHORIZED | Missing/invalid/expired access token |
| 401 | AUTH_GENERIC | Login/password failures, inactive, locked |
| 401 | REFRESH_REUSE | Reused refresh family revoked |
| 403 | FORBIDDEN | Authenticated but not permitted (incl. last-admin, self-role) |
| 404 | NOT_FOUND | Missing or hidden work order/user |
| 409 | CONFLICT_VERSION | Stale work order version |
| 409 | EMAIL_TAKEN | Register duplicate |
| 429 | RATE_LIMITED | IP / route limiter |
| 500 | INTERNAL | Unexpected; no stack unless DEBUG_ERRORS=true |
| 503 | INTERNAL | /ready when Mongo down (body { status: "degraded" }) |
6.3 DTOs
type UserPublic = { id: string; email: string; name: string; role: 'admin' | 'user'; createdAt: string; updatedAt: string };
type UserAdmin = UserPublic & { isActive: boolean; lastLoginAt: string | null };
type WorkOrderPublic = {
id: string; title: string; description: string | null;
priority: 'low' | 'medium' | 'high';
status: 'pending' | 'in_progress' | 'done';
owner: { id: string; name: string; email: string };
version: number; createdAt: string; updatedAt: string;
};
type CursorPage<T> = { items: T[]; nextCursor: string | null };
type OffsetPage<T> = { items: T[]; page: number; limit: number; total: number };
Never return passwordHash, reset/session hashes, failedLoginCount, or lockedUntil.
6.4 Endpoints
POST /api/v1/auth/register { email, password, name } → 201 UserPublic
POST /api/v1/auth/login { email, password } → 200 UserPublic
POST /api/v1/auth/logout (auth or refresh cookie) → 204
POST /api/v1/auth/logout-all (auth) → 204
POST /api/v1/auth/refresh (refresh cookie) → 200 UserPublic
POST /api/v1/auth/forgot-password { email } → 200 { ok: true }
POST /api/v1/auth/reset-password { token, password } → 200 UserPublic
GET /api/v1/users/me → 200 UserPublic
PATCH /api/v1/users/me { name } → 200 UserPublic
POST /api/v1/users/me/password { currentPassword, newPassword } → 200 UserPublic
GET /api/v1/work-orders ?cursor&limit&status&priority&search → 200 CursorPage<WorkOrderPublic>
POST /api/v1/work-orders { title, description?, priority?, status? } → 201 WorkOrderPublic
GET /api/v1/work-orders/:id → 200 WorkOrderPublic
PATCH /api/v1/work-orders/:id { title?, description?, priority?, status?, version }
DELETE /api/v1/work-orders/:id { version } (JSON body) → 204
GET /api/v1/admin/users ?page&limit&role&search → 200 OffsetPage<UserAdmin>
PATCH /api/v1/admin/users/:id/role { role } → 200 UserAdmin
PATCH /api/v1/admin/users/:id/status { isActive } → 200 UserAdmin
GET /api/v1/admin/work-orders ?cursor&limit&status&priority&search → 200 CursorPage<WorkOrderPublic>
GET /api/v1/admin/metrics → 200 { users, workOrders, uptimeSeconds }
GET /health → 200 { status: "ok" }
GET /ready → 200 { status: "ok" } | 503 { status: "degraded" }
7. Data Model
User
email string unique, lowercase, indexed
passwordHash string
name string
role 'admin' | 'user'
isActive boolean
lastLoginAt Date?
failedLoginCount number
lockedUntil Date?
passwordReset { tokenHash?: string, expiresAt?: Date }
createdAt Date
updatedAt Date
No deletedAt on User.
WorkOrder
title, description?, priority, status, owner, version, createdAt, updatedAt, deletedAt?
RefreshSession
userId, familyId, tokenHash, usedAt?, revokedAt?, expiresAt, createdAt, userAgent?, ip?
Indexes:
- User: unique
{ email: 1 },{ role: 1 } - WorkOrder:
{ owner: 1, deletedAt: 1, createdAt: -1, _id: -1 },{ owner: 1, status: 1, deletedAt: 1 },{ owner: 1, priority: 1, deletedAt: 1 } - RefreshSession: unique
{ tokenHash: 1 },{ familyId: 1 },{ userId: 1 }, TTL onexpiresAt
8. Security
8.1 Passwords
bcrypt cost 12 via bcrypt.compare. Dummy hash for unknown emails. Cap length 72 in zod. Never log passwords or raw tokens.
8.2 Tokens
Access JWT: HS256, iss=mern-starter, aud=mern-starter-api, JWT_SECRET min 32 chars, reject placeholders (secret, changeme, replace-me). Rotation of JWT_SECRET invalidates access tokens; refresh sessions remain valid.
8.3 Cookies
| Name | Path | Max-Age | Flags |
|---|---|---|---|
access_token |
/ |
900 | httpOnly, SameSite=Lax, Secure in prod, host-only |
refresh_token |
/api/v1/auth |
604800 | same |
COOKIE_SECRET signs cookie-parser cookies (integrity only). It is not authentication.
8.4 Rate limits
| Scope | Key | Window | Max | Env |
|---|---|---|---|---|
| Global (except /health /ready) | IP | 60s | 300 | RATE_LIMIT_WINDOW_MS, RATE_LIMIT_MAX |
/auth/login |
IP | 15m | 10 | RATE_LIMIT_LOGIN_MAX |
/auth/forgot-password |
IP + email | 15m | 3 | RATE_LIMIT_FORGOT_MAX |
/auth/register |
IP | 15m | 10 | (login max) |
trust proxy hops from TRUST_PROXY_HOPS. In-process store (single instance).
8.5 CSRF / CORS / fetch
Same-origin only. CORS allowlist = CORS_ORIGIN (comma-separated), credentials: true. Frontend credentials: 'include'. CSRF tokens out of scope. Do not set cookie Domain. Do not enable urlencoded.
8.6 Injection
Recursive reject $/__proto__/constructor on body and query. Escape regex. HMAC cursors (never trust owner from cursor). Validate ObjectIds. Mongoose sanitizeFilter on user filters.
9. Project Structure
mern-starter-app/
├── package.json # workspaces: backend, frontend, packages/shared
├── package-lock.json
├── eslint.config.js
├── prettier.config.js
├── docker-compose.yml # dev: mongo + api + vite
├── docker-compose.prod.yml # mongo + api + nginx
├── nginx/nginx.conf
├── .github/workflows/ci.yml
├── .env.example
├── docs/SPEC.md
├── docs/GETTING_STARTED.md
├── docs/adr/
├── packages/shared/
├── backend/
│ ├── Dockerfile
│ ├── src/{config,models,repositories,services,controllers,routes,middleware,utils,schemas,app.ts,server.ts}
│ ├── src/scripts/seed.ts
│ └── tests/
└── frontend/
├── Dockerfile
└── src/{api,components,features,hooks,lib,pages,main.tsx}
10. Environment
Validated at boot with zod; exit 1 on failure.
| Key | Required | Notes |
|---|---|---|
PORT |
no | default 4000 |
NODE_ENV |
no | development | test | production |
MONGODB_URI |
yes | |
JWT_SECRET |
yes | min 32, not a placeholder |
COOKIE_SECRET |
yes | min 32 |
CORS_ORIGIN |
yes | comma-separated origins |
APP_URL |
yes | public SPA origin (reset links) |
SMTP_URL |
no | unused in MVP mailer |
TRUST_PROXY_HOPS |
no | default 0 |
DEBUG_ERRORS |
no | default false |
LOG_LEVEL |
no | default info |
RATE_LIMIT_* |
no | see §8.4 |
SEED_ADMIN_EMAIL |
no | default admin@example.com |
SEED_ADMIN_PASSWORD |
no | default Admin1234 |
SEED_USER_EMAIL |
no | default user@example.com |
SEED_USER_PASSWORD |
no | default User1234 |
VITE_APP_URL |
frontend | same as APP_URL |
VITE_API_URL |
frontend | empty in dev (relative /api/v1); prod may set origin |
No REFRESH_SECRET. Never commit real secrets.
11. Testing
CI Mongo: GitHub Actions services: mongo:7. Local: mongodb-memory-server when MONGODB_URI unset.
Mandatory backend integration
- AUTH-1/2 register+login cookies; duplicate email 409; inactive 401
- AUTH-4/6 refresh rotation; reuse revokes family; logout
- AUTH-7 forgot always 200; reset; token single-use
- AUTH-9/ADM-2 self-demote 403; last admin 403
- AUTH-11 five failures then lock (still 401)
- WO-2 owner isolation; WO-10 404 for others
- WO-6/9 stale version 409; omitted version 400
- WO-7 soft delete 204 then GET 404
- WO-4 regex escaped
- Envelope shape 400/401/403/404/409/429
- Extra fields on POST /work-orders do not persist
Frontend: auth forms (zod), protected route redirect, 409 conflict UI. Coverage thresholds in vitest config (backend 80%, frontend 60% of included files). Excludes: server.ts, Docker, generated.
12. CI/CD
.github/workflows/ci.yml on Node 20:
- lint
- typecheck
- test + coverage (fail on threshold)
- build
- docker build (PRs and main)
13. Definition of Done
- §5 IDs implemented and covered by the test matrix.
- Lint, typecheck, tests pass with configured thresholds.
- README + GETTING_STARTED document setup, env, seed credentials.
docker compose up --buildserves the SPA and API on one browser origin.- Seed produces admin + user.
/health200; register → login → create a work order works.- No secrets committed.
14. Open Questions (resolved)
- Mailer → log-only, redacted token. Real SMTP later.
- Users →
isActiveonly; no user hard/soft delete. - npm workspaces + Vite/nginx same-origin proxy.
- React Query + auth context (
useMe). No zustand. - i18n deferred; en-US.
Remaining: none for v1.
15. Risks
| Risk | Sev | Mitigation |
|---|---|---|
| JWT secret leak | critical | min length, reject placeholders, 15m access TTL |
| Cookie CSRF on split origin | major | Same-origin only; no urlencoded; documented in ADR-0001 |
| Register email enumeration | minor | Accepted (EMAIL_TAKEN); forgot-password does not leak |
| Account lockout DoS | major | IP limiter first; lockout hidden behind AUTH_GENERIC |
| Refresh reuse vs parallel refresh | major | Client single-flight + 10s grace |
| NoSQL injection | major | Recursive key reject + zod + ObjectId + HMAC cursor |
| In-process rate limits | minor | Single-instance assumption |
| Last-admin race | major | Count admins in service before write; DB role on each request |
| bcrypt vs load SLO | minor | SLO removed from DoD |
16. Glossary
- Cursor — HMAC-protected token of
{ createdAt, id }. - Family — lineage of rotated refresh sessions; reuse revokes the lineage.
- httpOnly cookie — not readable from JS.
- Soft delete —
deletedAtset; hidden from default reads. - Work order — a job owned by a technician, tracked
pending → in_progress → done. - Actor —
{ id, role }loaded from the database after JWT verify.
End of specification. v1.1 is the implementation contract.