# Gate Pass — Backend API Reference

> Express.js + TypeScript API. Entry point: `apps/api/src/server.ts`. Runs on port 4200.

---

## Tech Stack

| Category | Technology | Version |
|----------|-----------|---------|
| Runtime | Node.js + TypeScript | ES2020 target |
| Framework | Express | 4.22.1 |
| Real-time | Socket.IO | 4.8.3 |
| Auth | JWT + bcryptjs | jsonwebtoken 9 + bcryptjs 3 |
| ORM | Prisma | 5.22.0 |
| Database | PostgreSQL | — |
| Email | Nodemailer | 8.0.4 |
| Mobile Push | Firebase Admin SDK (FCM) | 13.10.0 |
| Web Push | web-push (VAPID) | 3.6.7 |
| QR Generation | qrcode | 1.5.4 |
| Security | Helmet + CORS | 8.1.0 + 2.8.6 |
| Process Manager | PM2 (production) | — |

---

## Source Structure

```
api/src/
├── server.ts                   # Express + Socket.IO bootstrap
├── config/
│   ├── database.ts             # Prisma client singleton
│   ├── mailer.ts               # Nodemailer transport
│   └── storage.ts              # File upload helpers (local disk / uploads/)
├── middleware/
│   ├── auth.ts                 # JWT verification + role extraction
│   └── errorHandler.ts         # Global error → JSON response
├── controllers/
│   ├── visitors.controller.ts  # Visitor CRUD, approve/reject, bulk import
│   ├── visitor-scanner.controller.ts  # Checkpoint scan, check-in, walk-in
│   ├── approver.controller.ts  # Approver-scoped visitor actions
│   ├── devices.controller.ts   # FCM device token register/unregister
│   ├── auth.controller.ts      # OTP issue + verify
│   ├── admins.controller.ts    # Sub-admin CRUD
│   ├── departments.controller.ts
│   ├── emailTemplates.controller.ts
│   ├── emailAutomation.controller.ts
│   ├── automationTimings.controller.ts
│   ├── platform.controller.ts  # Vendor console
│   ├── pushSubscription.controller.ts  # VAPID web push
│   ├── walkInQR.controller.ts
│   └── notifications.controller.ts
├── routes/
│   ├── auth.routes.ts
│   ├── visitors.routes.ts
│   ├── visitor-scanner.routes.ts
│   ├── approver.routes.ts
│   ├── departments.routes.ts
│   ├── emailTemplates.routes.ts
│   ├── emailAutomation.routes.ts
│   ├── automationTimings.routes.ts
│   ├── platform.routes.ts
│   ├── pushSubscription.routes.ts
│   └── walkInQR.routes.ts
├── lib/
│   ├── events.ts               # recordNotification() — DB + socket + push dispatch
│   ├── push.ts                 # Firebase Admin FCM sender
│   ├── webPush.ts              # VAPID web push sender
│   ├── reminderCron.ts         # Visit reminder cron (every 30 min)
│   ├── publicUrl.ts            # Rewrite /uploads/ paths to absolute URLs
│   └── emailTemplates/         # Handlebars-style email builders
├── prisma/
│   └── schema.prisma           # Full DB schema
├── utils/
│   └── qrcode.ts               # QR PNG generation helper
└── scripts/
    ├── seed.ts                 # Seed DB with dummy data
    ├── smoke.ts                # End-to-end API smoke test
    ├── test-push.ts            # FCM push notification test
    └── ...
```

---

## Authentication

### Middleware

| Middleware | Accepts | Extracts |
|-----------|---------|----------|
| `requireAuth` | OWNER or ADMIN Bearer token | `req.ownerId`, `req.adminId?`, `req.adminFlags?` |
| `requireApproverAuth` | APPROVER Bearer token | `req.approverId`, `req.ownerId` |
| `requireAuthOrPlatform` | OWNER / ADMIN / APPROVER / PLATFORM Bearer | Combined |
| `requirePlatformAuth` | PLATFORM_ADMIN Bearer token | `req.platformAdminId` |
| `requireScanAccess` | OWNER / ADMIN (with `canScanCheckpoint`) or Checkpoint token | `req.scanContext` |

### JWT Payloads by Role

| Role | JWT payload |
|------|------------|
| OWNER | `{ ownerId }` |
| ADMIN | `{ ownerId, adminId, adminFlags, parentAdminId? }` |
| APPROVER | `{ approverId, ownerId, approverFlags }` |
| CHECKPOINT | `{ visitorCheckpointId, ownerId }` |
| PLATFORM_ADMIN | `{ platformAdminId }` |

Token expiry: **30 days**. Every request must include `Authorization: Bearer <token>`.

---

## API Routes

> **Params column legend:** `field` = required, `field?` = optional. Path/query/body params are separated by `<br>` within each cell. Types are the JS type accepted by the handler; enums are spelled out.

### Auth — `/api/auth`

| Method | Endpoint | Auth | Params | Response |
|--------|----------|------|--------|----------|
| POST | `/api/auth/send-otp` | None | Body: `email` (string) | `{ message, devOtp? }` (`devOtp` only when `EXPOSE_OTP_IN_RESPONSE=true`) |
| POST | `/api/auth/verify-otp` | None | Body: `email` (string), `otp` (string) | `{ role: 'OWNER'\|'ADMIN'\|'APPROVER', token, owner\|admin\|approver, isNew? }` |
| GET | `/api/auth/me` | Bearer | — | Owner or Admin object |
| PATCH | `/api/auth/me` | Bearer | Body: `name` (string, 1–100 chars) | Updated Owner/Admin |
| GET | `/api/auth/dev-peek-otp` | None (404 unless `EXPOSE_OTP_IN_RESPONSE=true`) | Query: `email` (string) | `{ devOtp, expiresAt }` |
| DELETE | `/api/visitors/account` | Bearer | — | `{ ok: true, deleted: 'admin' }` (owner gets `409` — must email support) |

### Visitors — `/api/visitors`

#### Visitor CRUD

| Method | Endpoint | Auth | Params | Notes |
|--------|----------|------|--------|-------|
| GET | `/api/visitors` | Bearer/Platform | Query: `search?` (string — matches name/email/mobile) | `[Visitor]` incl. owner/approver/admin refs, last scan log |
| GET | `/api/visitors/counts` | Bearer/Platform | — | `{ awaitingApproval, walkInPending }` (cross-org for platform tokens) |
| POST | `/api/visitors` | Bearer | Body: `name`, `email?`, `mobile?`, `designation?`, `companyName?`, `reasonForVisit`, `notes?`, `visitDate?`, `visitTime?`, `photoUrl?` (dataURL), `requiresApproval?` (bool), `assignedApproverId?`, `expiresAt?`, `isManualEntry?` (bool), `isWalkIn?` (bool), `isFrequent?` (bool), `frequencyType?` (`DAILY\|WEEKLY\|MONTHLY\|CUSTOM`), `frequencyValidFrom?`, `frequencyValidUntil?`, `frequencyWeekdays?` (`number[]` 0-6), `sendInviteEmail?` (bool) | Creates visitor (201); walk-in + `requiresApproval` → `AWAITING_APPROVAL` + push; blocked by `canBackdateVisitor`/check-in policy flags |
| POST | `/api/visitors/bulk` | Bearer | Body: `visitors` (array, 1–500), each row: `name`, `reasonForVisit`, `visitDate?`, `visitTime?`, `isManualEntry?`, `requiresApproval?`, `approverEmail?`, `expiryDate?`, `expiryTime?`, `email?`, `mobile?`, `notes?` | `{ created[], errors[], summary }` (201); no invite emails sent |
| GET | `/api/visitors/by-short-id/:shortId` | Bearer | Path: `shortId` | Single visitor + last scan log |
| GET | `/api/visitors/:id` | Bearer | Path: `id` | Single visitor with scan logs |
| PUT | `/api/visitors/:id` | Bearer | Path: `id`. Body (all optional): `name`, `email`, `mobile`, `designation`, `companyName`, `reasonForVisit`, `notes`, `visitDate`, `visitTime`, `status`, `photoUrl`, `requiresApproval`, `assignedApproverId`, `expiresAt` | Updates visitor fields; writes `VisitorEditLog` diff |
| DELETE | `/api/visitors/:id` | Bearer | Path: `id` | Requires `canManageVisitors`; cascades scan logs/notifications |
| GET | `/api/visitors/:id/history` | Bearer | Path: `id` | Field-level edit audit log (latest 100) |
| POST | `/api/visitors/:id/checkout` | Bearer | Path: `id` | Manual check-out (`CHECKED_OUT`); no-op if already checked out |
| POST | `/api/visitors/:id/approve-scan` | Bearer | Path: `id`. Body: `approvalNote?` | `AWAITING_APPROVAL → ARRIVED`; 403 if assigned to a different admin |
| POST | `/api/visitors/:id/reject-scan` | Bearer | Path: `id`. Body: `approvalNote?` | `AWAITING_APPROVAL → REJECTED` |

#### Sub-Admins

| Method | Endpoint | Auth | Params | Notes |
|--------|----------|------|--------|-------|
| GET | `/api/visitors/admin-me` | Bearer (ADMIN) | — | Admin profile + org |
| GET | `/api/visitors/admins` | Bearer (Owner or sub-admin manager) | — | Owner sees all; admin sees only ones they created |
| POST | `/api/visitors/admins` | Bearer (Owner or sub-admin manager) | Body: `name`, `email`, `isActive?` (default true), `phone?`, `designation?`, `department?`, and permission flags `canManageVisitors?`, `canManageApprovers?`, `canManageSettings?`, `canApproveRequests?`, `isApprover?`, `canAddVisitors?`, `canBackdateVisitor?`, `canScanCheckpoint?`, `canManageSubAdmins?` (owner-only), `canSeeAllVisitors?` (owner-only), `canPolicyAuto?`, `canPolicyLive?`, `canPolicyPre?`, `canPolicyWalkIn?`, `canPolicyManual?` | Created Admin (201); 409 if email in use; flags capped to parent's when creator is an admin |
| PUT | `/api/visitors/admins/:id` | Bearer (Owner or sub-admin manager) | Path: `id`. Body: same fields as create, all optional | Updated Admin |
| DELETE | `/api/visitors/admins/:id` | Bearer (Owner or sub-admin manager) | Path: `id` | Revokes `AllowedEmail`, nulls `createdByAdminId` on their visitors |

#### Approvers

| Method | Endpoint | Auth | Params | Notes |
|--------|----------|------|--------|-------|
| GET | `/api/visitors/approvers` | Bearer | — | List, enriched with `addedBy` + pending/approved/rejected/total counts |
| POST | `/api/visitors/approvers` | Bearer (`canManageApprovers`) | Body: `name`, `email`, `phone?`, `designation?`, `department?`, `canAddVisitors?` (bool), `canScanCheckpoint?` (bool) | Created Admin (`isApprover:true`); 409 if email in use |
| PUT | `/api/visitors/approvers/:id` | Bearer (`canManageApprovers`) | Path: `id`. Body: `name?`, `email?`, `phone?`, `designation?`, `department?`, `isActive?`, `canAddVisitors?`, `canScanCheckpoint?` | Updated Admin; re-upserts `AllowedEmail` if email changed |
| DELETE | `/api/visitors/approvers/:id` | Bearer (`canManageApprovers`) | Path: `id` | Unassigns their visitors |
| GET | `/api/visitors/approvers/activity` | Bearer | — | Up to 500 visitor rows with an assigned approver |
| GET | `/api/visitors/approvers/:id/history` | Bearer | Path: `id` | Up to 200 visitor rows for that approver |

#### Checkpoints

| Method | Endpoint | Auth | Params | Notes |
|--------|----------|------|--------|-------|
| GET | `/api/visitors/checkpoints` | Bearer | — | List with request/scan counts |
| POST | `/api/visitors/checkpoints` | Bearer (`canManageSettings`) | Body: `name?` (default `'Visitor Check-in'`), `personName`, `mobile`, `password` (bcrypt-hashed) | Created checkpoint (201) |
| PUT | `/api/visitors/checkpoints/:id` | Bearer (`canManageSettings`) | Path: `id`. Body: `name?`, `personName?`, `mobile?`, `password?` (rehashed), `isActive?` | Updated checkpoint |
| DELETE | `/api/visitors/checkpoints/:id` | Bearer (`canManageSettings`) | Path: `id` | Cascades its requests/scan logs |
| GET | `/api/visitors/checkpoints/:id/history` | Bearer | Path: `id` | `{ requests[], scans[] }` (latest 100 each) |

#### Reasons

| Method | Endpoint | Auth | Params | Notes |
|--------|----------|------|--------|-------|
| GET | `/api/visitors/reasons` | Bearer | — | Visit reason presets, sorted by name |
| POST | `/api/visitors/reasons` | Bearer (`canManageSettings`) | Body: `name` | 409 if name exists for owner |
| PUT | `/api/visitors/reasons/:id` | Bearer (`canManageSettings`) | Path: `id`. Body: `name` | 409 on unique conflict |
| DELETE | `/api/visitors/reasons/:id` | Bearer (`canManageSettings`) | Path: `id` | — |

#### Walk-in Requests

| Method | Endpoint | Auth | Params | Notes |
|--------|----------|------|--------|-------|
| GET | `/api/visitors/requests` | Bearer | Query: `status?` (e.g. `PENDING`) | `[VisitorRequest]` incl. checkpoint |
| POST | `/api/visitors/requests/:id/approve` | Bearer | Path: `id`. Body: `ownerNote?` | Materializes linked visitor to `ARRIVED`; 400 if not `PENDING` |
| POST | `/api/visitors/requests/:id/reject` | Bearer | Path: `id`. Body: `ownerNote?` | Updates linked visitor to `REJECTED` |

#### Notifications & Devices

| Method | Endpoint | Auth | Params | Notes |
|--------|----------|------|--------|-------|
| GET | `/api/visitors/notifications` | Bearer | Query: `before?` (ISO date cursor), `limit?` (1–100, default 50) | `{ notifications[], unread, nextCursor, hasMore }` |
| POST | `/api/visitors/notifications/:id/read` | Bearer | Path: `id` | `{ ok: true }` |
| POST | `/api/visitors/notifications/read-all` | Bearer | — | `{ ok: true }` |
| POST | `/api/visitors/devices/register` | Bearer (any role incl. checkpoint) | Body: `token`, `platform` (`ios\|android\|web`) | Upserts `DeviceToken`, dedupes by token |
| POST | `/api/visitors/devices/unregister` | Bearer | Body: `token` | Deletes matching `DeviceToken` rows |

### Approver — `/api/approver`

| Method | Endpoint | Auth | Params | Notes |
|--------|----------|------|--------|-------|
| POST | `/api/approver/send-otp` | None | Body: `email` | — |
| POST | `/api/approver/verify-otp` | None | Body: `email`, `otp` | `{ role, token, approver }` |
| GET | `/api/approver/me` | Approver Bearer | — | Profile + org + `canAddVisitors`/`canScanCheckpoint` |
| GET | `/api/approver/pending` | Approver Bearer | — | Visitors `AWAITING_APPROVAL` assigned to this approver |
| GET | `/api/approver/history` | Approver Bearer | — | Visitors `ARRIVED`/`REJECTED` assigned to approver (max 50) |
| GET | `/api/approver/visitors` | Approver Bearer | — | All visitors assigned to or created by this approver |
| GET | `/api/approver/visitors/by-short-id/:shortId` | Approver Bearer | Path: `shortId` | Full visitor row or 404 |
| GET | `/api/approver/counts` | Approver Bearer | — | `{ awaitingApproval, walkInPending, expected, arrived, total }` |
| GET | `/api/approver/reasons` | Approver Bearer | — | Visit reason presets |
| POST | `/api/approver/visitors` | Approver Bearer (`canAddVisitors`) | Body: `name`, `email?`, `mobile?`, `designation?`, `companyName?`, `reasonForVisit`, `notes?`, `visitDate?`, `visitTime?`, `expiresAt?`, `requiresApproval?` (bool-ish), `isWalkIn?` (bool-ish, only applied if `requiresApproval`), `sendInviteEmail?` (must be exactly `true`) | Created visitor (201); 400 if visit date/time in past |
| POST | `/api/approver/visitors/:id/approve` | Approver Bearer | Path: `id`. Body: `approvalNote?` | `→ ARRIVED`; 404 if not assigned, 400 if not `AWAITING_APPROVAL` |
| POST | `/api/approver/visitors/:id/reject` | Approver Bearer | Path: `id`. Body: `approvalNote?` | `→ REJECTED` |
| GET | `/api/approver/requests` | Approver Bearer | — | `[VisitorRequest]` assigned to approver |
| POST | `/api/approver/requests/:id/approve` | Approver Bearer | Path: `id`. Body: `ownerNote?` | `{ ...request, visitor }`; 400 if not `PENDING` |
| POST | `/api/approver/requests/:id/reject` | Approver Bearer | Path: `id`. Body: `ownerNote?` | `{ ...request, visitor }` |
| GET | `/api/approver/notifications` | Approver Bearer | Query: `before?`, `limit?` (1–100, default 50) | `{ notifications[], unread, nextCursor, hasMore }` |
| POST | `/api/approver/notifications/:id/read` | Approver Bearer | Path: `id` | `{ ok: true }` |
| POST | `/api/approver/notifications/read-all` | Approver Bearer | — | `{ ok: true, count }` |
| POST | `/api/approver/devices/register` | Approver Bearer | Body: `token`, `platform` (`ios\|android\|web`) | Registers FCM token |
| POST | `/api/approver/devices/unregister` | Approver Bearer | Body: `token` | Removes FCM token |
| DELETE | `/api/approver/account` | Approver Bearer | — | `{ ok: true, deleted: 'approver' }` |

### Visitor Scanner — `/api/visitor-scanner`

| Method | Endpoint | Auth | Params | Notes |
|--------|----------|------|--------|-------|
| POST | `/api/visitor-scanner/login` | None | Body: `mobile`, `password` | `{ token, checkpoint }`; 401 invalid creds, 403 suspended workspace |
| GET | `/api/visitor-scanner/reasons` | Scan | — | Visit reason presets |
| GET | `/api/visitor-scanner/stats` | Scan | — | `{ total, arrived }` |
| GET | `/api/visitor-scanner/scan-history` | Scan | — | Max 100 `VisitorScanLog` rows |
| GET | `/api/visitor-scanner/lookup/:query` | Scan | Path: `query` (search term) | Max 20 visitors matched by shortId/name/mobile |
| GET | `/api/visitor-scanner/status/:shortId` | Scan | Path: `shortId` | Single visitor or 404 |
| GET | `/api/visitor-scanner/approvers` | Scan | — | Active approvers `{ id, name, email, designation, department }` |
| POST | `/api/visitor-scanner/checkin` | Scan | Body: `shortId`, `confirmApprovalRequest?` (bool) | QR scan check-in — see flow below |
| POST | `/api/visitor-scanner/walk-in-arrived` | Scan | Body: `name`, `phone?`, `email?`, `reasonForVisit`, `notes?` | Creates visitor directly as `ARRIVED` (201) |
| POST | `/api/visitor-scanner/requests` | Checkpoint | Body: `name`, `phone?`, `email?`, `company?`, `reason`, `assignedApproverId?` | Creates visitor (`AWAITING_APPROVAL`) + `VisitorRequest` (201) |
| GET | `/api/visitor-scanner/requests` | Checkpoint | — | Max 50 requests for this checkpoint |
| POST | `/api/visitor-scanner/requests/:id/complete` | Checkpoint | Path: `id` | Marks request `APPROVED`; 400 if already processed |
| GET | `/api/visitor-scanner/notifications` | Checkpoint | Query: `before?`, `limit?` (1–100, default 50) | `{ notifications[], unread, nextCursor, hasMore }` |
| POST | `/api/visitor-scanner/notifications/:id/read` | Checkpoint | Path: `id` | `{ ok: true }` |
| POST | `/api/visitor-scanner/notifications/read-all` | Checkpoint | — | `{ ok: true }` |

#### Check-in Flow (`POST /api/visitor-scanner/checkin`)

```
Body: { shortId, confirmApprovalRequest? }

1. Validate shortId → find visitor
2. Terminal state checks (REJECTED, EXPIRED, CANCELLED) → 200/403 with flags
3. Frequent visitor re-entry: flip ARRIVED/CHECKED_OUT → EXPECTED (new visit)
4. requiresApproval=true, isPreApproval=false:
   a. First scan (EXPECTED):
      - confirmApprovalRequest=false → return 200 { approvalNeeded: true } (no state change)
      - confirmApprovalRequest=true  → status=AWAITING_APPROVAL
                                     → push to OWNER + assigned APPROVER
   b. Re-scan (AWAITING_APPROVAL)    → return 202 { approvalPending: true }
5. Auto check-in (requiresApproval=false OR isPreApproval=true):
   → status=ARRIVED
   → push to OWNER
   → send check-in confirmation email (if automation enabled)
```

### Decision Links — `/api/public/decision`

Public, single-use-token approve/reject links (e.g. emailed to an approver) — not gated by any Bearer auth; the token itself is the credential.

| Method | Endpoint | Auth | Params | Notes |
|--------|----------|------|--------|-------|
| GET | `/api/public/decision/:token` | None (token is auth) | Path: `token` (min 16 chars) | `{ visitorName, visitorMobile, visitorEmail, shortId, reason, notes, status, hostName, pending }`; 404 if invalid/expired/used |
| POST | `/api/public/decision/:token` | None (token is auth) | Path: `token`. Body: `action` (`approve\|reject`), `note?` | Sets visitor `ARRIVED`/`REJECTED`, clears token; 409 if already decided; `{ ok, action, status, visitorName }` |

### Walk-in QRs — `/api/walk-in-qrs` + `/api/walk-in` + `/api/public/qr`

| Method | Endpoint | Auth | Params | Notes |
|--------|----------|------|--------|-------|
| GET | `/api/walk-in-qrs` | Bearer | — | List all QR codes for the workspace |
| POST | `/api/walk-in-qrs` | Bearer (`canManageSettings`) | Body: `label`, `assignedAdminId?`, `requiresApproval?` (bool, default true) | 201 + `publicUrl` |
| PATCH | `/api/walk-in-qrs/:id` | Bearer (`canManageSettings`) | Path: `id`. Body: `label?`, `assignedAdminId?` (`null`/`''` clears), `isActive?`, `requiresApproval?` | Partial update |
| DELETE | `/api/walk-in-qrs/:id` | Bearer (`canManageSettings`) | Path: `id` | — |
| GET | `/api/walk-in/:code` | None (public) | Path: `code` | `{ orgName, label, hostName, reasons[] }`; 404 if inactive/missing |
| POST | `/api/walk-in/:code` | None (public, rate-limited 5/hr per IP+code) | Path: `code`. Body: `name`, `mobile?`, `email?`, `reasonForVisit` | Creates visitor (`EXPECTED`); 201 `{ shortId, name, qrCodeUrl, hostHint }` |
| GET | `/api/public/qr/:shortId.png` | None (public) | Path: `shortId` | Raw PNG QR image, cached 7 days |

### Settings

#### Email Templates — `/api/email-templates`

| Method | Endpoint | Auth | Params | Notes |
|--------|----------|------|--------|-------|
| GET | `/api/email-templates` | Bearer | — | 4 templates (`VISITOR_INVITE`, `CHECK_IN_CONFIRMATION`, `VISITOR_REMINDER`, `APPROVER_REQUEST`) + `variables[]` |
| PUT | `/api/email-templates/:type` | Bearer (`canManageSettings`) | Path: `type`. Body: `subject`, `body`, `designJson?`, `style?` (`headerBg?`, `headerText?`, `infoBoxBg?`) | Updated template |
| POST | `/api/email-templates/:type/preview` | Bearer | Path: `type`. Body: `subject`, `body`, `style?` | `{ subject, body, html }` rendered against sample data |
| GET | `/api/email-templates/:type/render-for-visitor/:visitorId` | Bearer | Path: `type`, `visitorId` | `{ subject, body, qrUrl }` |
| POST | `/api/email-templates/:type/restore-defaults` | Bearer (`canManageSettings`) | Path: `type` | Resets to shipped defaults |

#### Email Automation — `/api/email-automation`

| Method | Endpoint | Auth | Params | Notes |
|--------|----------|------|--------|-------|
| GET | `/api/email-automation` | Bearer (owner-only) | — | `EmailAutomation` row |
| PATCH | `/api/email-automation` | Bearer (`canManageSettings`, owner-only) | Body (all optional bool): `inviteEnabled`, `checkInEnabled`, `reminderEnabled`, `approverRequestEnabled` | 400 if no fields given |

#### Automation Timings — `/api/automation-timings`

| Method | Endpoint | Auth | Params | Notes |
|--------|----------|------|--------|-------|
| GET | `/api/automation-timings` | Bearer (owner-only) | — | `{ autoCheckoutTime, autoCancelTime, reminderHoursBefore, lastAutoCheckoutAt, lastAutoCancelAt }` |
| PATCH | `/api/automation-timings` | Bearer (`canManageSettings`, owner-only) | Body (all optional): `autoCheckoutTime?` (`HH:MM`), `autoCancelTime?` (`HH:MM`), `reminderHoursBefore?` (number, 1–168) | 400 on invalid format/range |

#### Departments — `/api/departments`

| Method | Endpoint | Auth | Params | Notes |
|--------|----------|------|--------|-------|
| GET | `/api/departments` | Bearer | — | Ordered by name |
| POST | `/api/departments` | Bearer (`canManageSettings`) | Body: `name` | 409 on duplicate |
| PUT | `/api/departments/:id` | Bearer (`canManageSettings`) | Path: `id`. Body: `name` | Also renames matching `Approver.department` strings |
| DELETE | `/api/departments/:id` | Bearer (`canManageSettings`) | Path: `id` | Clears `department` on matching approvers first |

#### WhatsApp Templates — `/api/whatsapp-templates`

| Method | Endpoint | Auth | Params | Notes |
|--------|----------|------|--------|-------|
| GET | `/api/whatsapp-templates` | Bearer | — | 4 templates + `variables[]` |
| PUT | `/api/whatsapp-templates/:type` | Bearer (`canManageSettings`) | Path: `type`. Body: `body` | — |
| POST | `/api/whatsapp-templates/:type/preview` | Bearer | Path: `type`. Body: `body` | `{ body, qrUrl? }` |
| GET | `/api/whatsapp-templates/:type/render/:visitorId` | Bearer | Path: `type`, `visitorId` | `{ body, qrUrl }` |
| POST | `/api/whatsapp-templates/:type/restore-defaults` | Bearer (`canManageSettings`) | Path: `type` | Resets `body` to default |

#### WhatsApp Automation — `/api/whatsapp-automation`

| Method | Endpoint | Auth | Params | Notes |
|--------|----------|------|--------|-------|
| GET | `/api/whatsapp-automation` | Bearer (owner-only) | — | `WhatsAppAutomation` row |
| PATCH | `/api/whatsapp-automation` | Bearer (`canManageSettings`, owner-only) | Body (all optional bool): `inviteEnabled`, `checkInEnabled`, `reminderEnabled`, `approverRequestEnabled` | 400 if no fields given |

### Push — `/api/push`

| Method | Endpoint | Auth | Params | Notes |
|--------|----------|------|--------|-------|
| GET | `/api/push/public-key` | None | — | `{ publicKey }`; 503 if not configured |
| POST | `/api/push/subscribe` | Bearer | Body: `subscription` (`{ endpoint, keys: { p256dh, auth } }`), `userAgent?` | Upserts by `endpoint`; `{ id }` |
| POST | `/api/push/unsubscribe` | Bearer | Body: `endpoint` | `{ ok: true }` |

### Platform — `/api/platform`

| Method | Endpoint | Auth | Params | Notes |
|--------|----------|------|--------|-------|
| POST | `/api/platform/auth/send-otp` | None | Body: `email` | 403 if not an active `PlatformAdmin` |
| POST | `/api/platform/auth/verify-otp` | None | Body: `email`, `otp` | `{ role: 'PLATFORM_ADMIN', token, platformAdmin }` |
| GET | `/api/platform/stats` | Platform Bearer | — | Platform-wide counts |
| GET | `/api/platform/orgs` | Platform Bearer | — | List all customer orgs w/ `_count` |
| POST | `/api/platform/orgs` | Platform Bearer | Body: `email`, `name?` | 409 if org exists for email |
| GET | `/api/platform/orgs/:id` | Platform Bearer | Path: `id` | Org + `_count` |
| PATCH | `/api/platform/orgs/:id` | Platform Bearer | Path: `id`. Body: `name` (`''` allowed to clear, max 120 chars) | — |
| POST | `/api/platform/orgs/:id/suspend` | Platform Bearer | Path: `id` | Sets `suspendedAt = now()` |
| POST | `/api/platform/orgs/:id/unsuspend` | Platform Bearer | Path: `id` | Sets `suspendedAt = null` |

### Health

| Method | Endpoint | Auth | Response |
|--------|----------|------|----------|
| GET | `/api/health` | None | `{ status: 'ok', timestamp }` |

---

## Push Notification System

### Mobile Push (FCM via Firebase Admin)

**File:** `src/lib/push.ts`

`sendPushToRecipient({ type, id }, { title, body, data })` — lazy-loads Firebase Admin, queries `DeviceToken` for the recipient, calls `messaging.sendEachForMulticast()`. Stale/invalid tokens are automatically pruned from the DB.

**RecipientType values:**

| Type | DeviceToken query |
|------|------------------|
| `OWNER` | `{ ownerId: id, adminId: null, approverId: null }` |
| `APPROVER` | `{ approverId: id } OR { adminId: id }` |
| `CHECKPOINT` | skipped — checkpoints don't receive mobile push |

**Firebase credential:** Set `FIREBASE_SERVICE_ACCOUNT_JSON` in `.env` with the service account JSON as a single-line string (single-quoted).

### Web Push (VAPID)

**File:** `src/lib/webPush.ts`

`sendWebPushToRecipient()` — queries `PushSubscription` table, sends via `web-push` library. Dead endpoints (404/410) are deleted automatically.

### Triggering Push

All push dispatches go through `recordNotification()` in `src/lib/events.ts`. Pass `push: { data: { ... } }` to trigger FCM + VAPID simultaneously:

```typescript
await recordNotification({
  recipientType: 'APPROVER',
  recipientId: adminId,
  type: 'visitor.awaiting',
  title: `${visitor.name} is at reception`,
  body: `Walk-in — tap to approve or reject · #${visitor.shortId}`,
  push: { data: { visitorId: visitor.id, shortId: visitor.shortId, kind: 'visitor.awaiting' } },
});
```

### When Push Fires

| Trigger | Who gets push | `kind` |
|---------|--------------|--------|
| Walk-in visitor created (`isWalkIn=true`, `requiresApproval=true`) | Owner + assigned approver | `visitor.awaiting` |
| QR scan → `confirmApprovalRequest=true` | Owner + assigned approver | `visitor.awaiting` |
| QR scan → auto ARRIVED (no approval) | Owner | `visitor.arrived` |
| Checkpoint form → AWAITING_APPROVAL | Owner + assigned approver | `visitor.awaiting` |
| Checkpoint direct walk-in → ARRIVED | Owner | `visitor.arrived` |

---

## Socket.IO

**Rooms:** `owner:{ownerId}` · `approver:{approverId}` · `checkpoint:{checkpointId}`

**Server-emitted events:**

| Event | Fired when | Data |
|-------|-----------|------|
| `visitor.awaiting` | Visitor enters AWAITING_APPROVAL | `{ visitor }` |
| `visitor.arrived` | Visitor checks in (ARRIVED) | `{ visitor }` |
| `visitor.decided` | Approve or reject action | `{ visitor }` |
| `request.created` | Walk-in request submitted | `{ request }` |
| `notification.new` | New notification created | `{ notification }` |
| `notification.read` | Notification marked read | `{ id, readAt }` |
| `notification.deleted` | Notification deleted | `{ id }` |

---

## Database Models

| Model | Purpose |
|-------|---------|
| `Owner` | Workspace root; tenant anchor |
| `Admin` | Sub-admin with 11 permission flags; self-referential hierarchy |
| `Approver` | Legacy role (being merged into Admin with `isApprover=true`) |
| `PlatformAdmin` | Vendor-level console admin |
| `AllowedEmail` | OTP login whitelist per workspace |
| `Visitor` | Core entity — full lifecycle tracking |
| `VisitorCheckpoint` | Reception desk / scanner station |
| `VisitorRequest` | Walk-in approval request (temporary state) |
| `VisitorScanLog` | Audit trail — every checkpoint scan event |
| `VisitorEditLog` | Field-level edit history (JSON diff per field) |
| `WalkInQR` | Printable lobby QR poster config |
| `Notification` | In-app + push notification record |
| `Department` | Configurable org structure |
| `VisitorReason` | "Reason for visit" preset labels |
| `EmailTemplate` | Transactional email HTML + design JSON |
| `EmailAutomation` | Master on/off switches for email automations |
| `AutomationTimings` | Configurable HH:MM for auto-checkout + auto-cancel |
| `DeviceToken` | Mobile FCM/APNs tokens (OWNER/ADMIN/APPROVER) |
| `PushSubscription` | Web Push VAPID subscriptions |
| `OtpToken` | OTP state — email, hash, expiry, used flag |

---

## Cron Jobs

| Job | Schedule | What it does |
|-----|---------|-------------|
| Visit Reminder | Every 30 min | Emails visitors ~24h before scheduled visit; deduplicated via `reminderSentAt` |
| Auto-Checkout | Nightly at configured `autoCheckoutTime` | Sweeps ARRIVED visitors from yesterday → CHECKED_OUT |
| Auto-Cancel | At `autoCancelTime` | Cancels stale AWAITING_APPROVAL visitors past TTL |

---

## Environment Variables

| Variable | Required | Description |
|----------|---------|-------------|
| `PORT` | No (default 4200) | Express listen port |
| `FRONTEND_URL` | Yes | Comma-separated allowed origins for CORS (must include `capacitor://localhost` for iOS) |
| `PUBLIC_API_URL` | Yes | Base URL for generating absolute upload URLs |
| `DATABASE_URL` | Yes | PostgreSQL connection string |
| `JWT_SECRET` | Yes | JWT signing secret (min 32 chars in production) |
| `MAIL_HOST` | Yes (for email) | SMTP host |
| `MAIL_PORT` | Yes (for email) | SMTP port |
| `MAIL_USERNAME` | Yes (for email) | SMTP username |
| `MAIL_PASSWORD` | Yes (for email) | SMTP password |
| `MAIL_FROM_ADDRESS` | Yes (for email) | From address |
| `MAIL_FROM_NAME` | No | From display name |
| `FIREBASE_SERVICE_ACCOUNT_JSON` | Yes (for push) | Full service account JSON as single-line string |
| `FIREBASE_SERVICE_ACCOUNT_PATH` | Alt to above | Path to service account JSON file |
| `VAPID_PUBLIC_KEY` | Yes (for web push) | VAPID public key |
| `VAPID_PRIVATE_KEY` | Yes (for web push) | VAPID private key |
| `VAPID_SUBJECT` | Yes (for web push) | `mailto:` or URL |
| `EXPOSE_OTP_IN_RESPONSE` | No (default false) | Include OTP in verify-otp response (dev only) |

---

## NPM Scripts

| Script | Command | Purpose |
|--------|---------|---------|
| `npm run dev` | `nodemon --exec ts-node -r dotenv/config src/server.ts` | Development server with hot reload |
| `npm run build` | `tsc` | Compile TypeScript → `dist/` |
| `npm run start` | `node dist/server.js` | Production start (after build) |
| `npm run prisma:push` | `prisma db push --schema=...` | Push schema to DB (no migration history) |
| `npm run prisma:generate` | `prisma generate --schema=...` | Regenerate Prisma client |
| `npm run prisma:studio` | `prisma studio --schema=...` | Open Prisma Studio browser |
| `npm run seed` | `ts-node -r dotenv/config src/scripts/seed.ts` | Seed DB with dummy data |
| `npm run smoke` | `ts-node -r dotenv/config src/scripts/smoke.ts` | API end-to-end smoke tests |
| `npm run test:push` | `ts-node -r dotenv/config src/scripts/test-push.ts` | Test FCM push end-to-end |
