# Gate Pass — Implementation Guide

A practical, end-to-end map of the Gate Pass visitor / gate-pass platform: what the moving parts are, how a visitor flows through them, and where to plug new work in.

> **Naming note:** the product is **Gate Pass**. The repo folder is `Gate Pass`; ignore the legacy folder name when generating strings, env vars, or identifiers. Use `Gate Pass`, `gatepass.io`, `gatepass-assets`, `gatepass` (DB).

---

## 1. System at a glance

Gate Pass is a multi-tenant SaaS for managing on-premises visitors and gate passes. Each workspace (an `Owner` row) is one tenant. The product is split into two deployable packages:

| Package | Stack | Purpose |
|---|---|---|
| `api/` | Node.js + Express + TypeScript, Prisma, PostgreSQL, Socket.IO | REST API, realtime push, cron jobs, email |
| `web/` | React 18 + Vite + TypeScript | Admin console and reception/mobile UI |

Out of scope for this guide: the `web/ios`, `web/android`, and `web/electron` shells. Treat the web app as the only frontend in active scope.

---

## 2. Tenancy and identity model

Every row that holds workspace data hangs off an `Owner`. The auth model has four principal types:

- **`PlatformAdmin`** — the product vendor (us). Logs in at `/platform/login` via OTP against a separate allow-list. Can list, browse, suspend, and unsuspend `Owner` workspaces.
- **`Owner`** — the workspace super-admin. One per tenant. All workspace data is scoped by `ownerId`.
- **`Admin`** — sub-admins under an `Owner`. Same UI; capabilities gated by per-row feature flags (`canManageVisitors`, `canApproveRequests`, `canScanCheckpoint`, `canSeeAllVisitors`, …). An `Admin` with `isApprover=true` shows up in the approver list.
- **`Approver`** (legacy) — being merged into `Admin`. New writes go to `Visitor.assignedAdminId`; `assignedApproverId` is kept for back-compat reads.
- **`VisitorCheckpoint`** — reception identity used by the scanner app. Has its own password (`passwordHash`).

### JWT shape

The auth middleware (`requireAuth`) populates:

- `req.organizerId` — the tenant (`Owner.id`)
- `req.actorEmail` — the signed-in human (Owner email for owners, Admin email for sub-admins)

For an `Owner` they match. For an `Admin` acting under an owner they differ — `actorEmail` is always the human actor for audit purposes.

### Tenant isolation invariant

Every query that returns workspace data must filter by `ownerId`. Never write an endpoint that could leak data across tenants. This applies to visitor lists, scan logs, notifications, walk-in QRs, departments, reasons, email templates — everything.

---

## 3. The Visitor lifecycle

`Visitor` is the central object. Its state machine is the `VisitorStatus` enum:

```
EXPECTED ──┐
           ├─► ARRIVED ──► CHECKED_OUT
AWAITING_APPROVAL ──► EXPECTED   (pre-approval path)
                    └► REJECTED
EXPECTED / AWAITING_APPROVAL ──► CANCELLED / EXPIRED  (cron sweeps)
```

Key semantics:

- **`EXPECTED`** — registered and good to walk in. The scanner can convert them to `ARRIVED` without a host prompt.
- **`AWAITING_APPROVAL`** — the host/approver needs to decide. Two sub-cases:
  - **Live approval** — reception scanned the QR; the host gets a realtime prompt and the visitor is stuck at the gate until they decide.
  - **Pre-approval** (`isPreApproval=true`) — the host decides before arrival. On approval the row flips to `EXPECTED`; reception's later scan just stamps `ARRIVED`.
- **`ARRIVED`** — currently in the building. `checkedOutAt` is `null`.
- **`CHECKED_OUT`** — came in and left. Either reception clicked "Check out" or the auto-checkout cron swept them at the workspace's configured `autoCheckoutTime`.
- **`REJECTED` / `CANCELLED` / `EXPIRED`** — terminal failure states.

### Identifiers

- `Visitor.id` — internal cuid.
- `Visitor.shortId` — unguessable token embedded in the visitor's QR code. Use this on public-facing endpoints; never expose `id` to the QR / public web.

### Frequent visitors

`isFrequent=true` carries a non-expiring QR. They're excluded from auto-checkout, auto-cancel, and approval expiry. Scanning an already-`ARRIVED` frequent visitor re-arrives them (fresh `arrivedAt`, new scan log) rather than returning "already arrived". The check-in policy still gates each entry — approval-required frequent visitors still ping the host every scan.

`frequencyType=CUSTOM` activates a real validity window via `frequencyValidFrom` / `frequencyValidUntil` plus an optional weekday allow-list (`frequencyWeekdays`, 0=Sun..6=Sat). Outside those constraints the scanner returns "Entry expired — not valid today" without touching status.

---

## 4. Entry paths (how a Visitor row is born)

Four supported policies — every entry uses one:

1. **`auto`** — visitor pre-registered by an admin/approver; QR works without further approval.
2. **`live`** — visitor pre-registered; reception's scan parks them in `AWAITING_APPROVAL` and pings the host for a live decision.
3. **`pre`** — visitor pre-registered with `isPreApproval=true`; host decides before the day.
4. **`walkIn`** — visitor self-registers from the lobby (via a `WalkInQR` poster) or reception submits a `VisitorRequest`.

Per-admin allow-list lives on the `Admin` row (`canPolicyAuto`, `canPolicyLive`, `canPolicyPre`, `canPolicyWalkIn`). Owners are always allowed all four.

### Walk-in QR posters (`WalkInQR`)

`WalkInQR` is a reusable printed poster owned by the workspace. When a lobby visitor scans it with their phone, they hit a public form (no auth) that materialises a `Visitor` and a `VisitorRequest` tied to the QR's `assignedAdminId`. `requiresApproval` on the QR row controls whether reception's scan parks them in `AWAITING_APPROVAL` or sends them straight to `ARRIVED`.

The `code` column is the unguessable token embedded in the public URL — never expose the row `id`.

---

## 5. Scanning at the gate (`VisitorScanLog`)

The reception app authenticates as either a `VisitorCheckpoint` row or an `Admin` / `Approver` with `canScanCheckpoint=true`. Each successful scan writes a `VisitorScanLog` row.

Re-scan rules:

- A non-frequent `ARRIVED` visitor returns an informational "already arrived" response without a new log row.
- A frequent visitor re-arrives — new scan log, refreshed `arrivedAt`.
- Custom-cadence frequent visitors outside their validity window get rejected at scan time with `"Entry expired — not valid today"`.

Endpoints live under `/api/visitor-scanner` (the scanner UI) and `/api/visitors` (admin console).

---

## 6. Approval flow

### Live approval (at the gate)

1. Reception scans → `Visitor.status` flips to `AWAITING_APPROVAL`, `approvalRequestedAt` stamped.
2. Host gets a realtime push via Socket.IO + Web Push (`PushSubscription`) + FCM (`DeviceToken`). If `EmailAutomation.approverRequestEnabled` is on, they also get an email with `Approve` / `Reject` buttons backed by `Visitor.decisionToken` (one-shot, consumed on click).
3. Host decides in-app, on the mobile, or via the email's `/decide/:token` public page.
4. On approve: status → `ARRIVED`. On reject: status → `REJECTED`. Token is cleared.

### Pre-approval (before the day)

1. Admin adds visitor with `isPreApproval=true` and `requiresApproval=true`. Status starts at `AWAITING_APPROVAL`.
2. Host approves: status flips to `EXPECTED`. The visitor's QR is good to walk in.
3. Reception's scan on the day just stamps `ARRIVED` without re-prompting.

### Audit

Every approval/rejection is captured in `ApprovalLog` (referenced from the activity feed; see §10).

---

## 7. Notifications

Three transport layers, all dispatched from a single notification helper:

- **In-app** — `Notification` rows. `recipientType` is `OWNER` / `APPROVER` / `CHECKPOINT`, paired with `recipientId`. The web app polls / subscribes for the unread feed.
- **Web Push** — `PushSubscription` (one row per `endpoint`). Dispatcher deletes rows on a 410-Gone response from the push service.
- **FCM** (mobile) — `DeviceToken` (one row per `token`). Each device upserts on `token`.

A signed-in user can have many devices; cleanup is workspace-scoped via `ownerId`.

---

## 8. Email

`EmailTemplate` holds the per-workspace copy. One row per (`ownerId`, `type`); current types:

- `VISITOR_INVITE` — fires when an admin adds a visitor (if the master gate + per-visitor toggle are on).
- `CHECK_IN_CONFIRMATION` — fires when reception's scan flips status to `ARRIVED`.
- `VISITOR_REMINDER` — fires ~24h before the visit, deduped per-visitor by `Visitor.reminderSentAt`.
- `APPROVER_REQUEST` — fires when a visitor enters `AWAITING_APPROVAL`. Carries the `decisionToken` link.

`designJson` is the Unlayer editor's design tree (stored verbatim); `body` is the exported HTML. When `designJson` is null the body is treated as a legacy plain-text template (branded shell + `escapeHtml(body)`).

`EmailAutomation` holds the workspace master gates (`inviteEnabled`, `checkInEnabled`, `reminderEnabled`, `approverRequestEnabled`). Owner-only edit; sub-admins can't change automation.

---

## 9. Cron sweeps

One process runs all of them; entrypoint is `startVisitReminderCron()` in `api/src/lib/reminderCron.ts`, called from `server.ts`.

| Sweep | Trigger | What it does |
|---|---|---|
| Auto-checkout | Workspace's `autoCheckoutTime` (default `00:00`) | Flips yesterday's `ARRIVED` non-frequent visitors to `CHECKED_OUT`. Skips frequent visitors. |
| Auto-cancel | Workspace's `autoCancelTime` (default `00:01`) | Cancels `EXPECTED` visitors whose `visitDate` is in the past. |
| Visit reminder | `reminderHoursBefore` (default 24) | Sends the `VISITOR_REMINDER` email. `reminderSentAt` dedupes. |

Sweeps compare `HH:MM` against server-local wall-clock minute-of-day. `lastAutoCheckoutAt` / `lastAutoCancelAt` dedupe within a calendar day so a 30-min tick can't fire twice.

---

## 10. Audit & activity feed

Two log models:

- **`VisitorEditLog`** — captured by the update-visitor endpoint. Stores `editedByAdminId` (null = owner), a display snapshot (`editedByName`, `editedByEmail`), and a compact `{ field: { from, to } }` JSON for whitelisted user-editable fields.
- **`ActivityLog` / `ApprovalLog`** — every mutating controller calls `logActivity(req, { ... })`. Actor email comes from `req.actorEmail` (set by `requireAuth`). **Never attribute an action to a generic "organizer"** — always the human actor.

`GET /api/events/:id/activity` merges `ActivityLog` and `ApprovalLog` into a single feed sorted descending. Do not split this into separate endpoints.

### Invariant

Every controller that creates, updates, or deletes user data must call `logActivity(req, { ... })`. New mutation endpoints without an audit call are incomplete.

---

## 11. API surface map

Mounted under `/api`:

| Route prefix | Owner |
|---|---|
| `/auth` | OTP login + JWT issue |
| `/visitors` | Admin console CRUD on `Visitor` |
| `/visitor-scanner` | Reception app (scan, lookup, mark arrived) |
| `/approver` | Approver-scoped views and decisions |
| `/departments` | `Department` master list |
| `/email-templates` | `EmailTemplate` CRUD |
| `/email-automation` | `EmailAutomation` toggles |
| `/automation-timings` | Workspace auto-checkout / auto-cancel / reminder timings |
| `/push` | Web Push subscription register/unregister |
| `/platform` | `PlatformAdmin` console |
| `/walk-in-qrs` | `WalkInQR` CRUD (admin) |
| `/walk-in` | Public walk-in form (no auth, takes `code`) |
| `/public/decision` | One-shot `/decide/:token` approve / reject endpoint |
| `/health` | Liveness probe |

Realtime: Socket.IO server is on the same HTTP server. Clients `socket.emit('join', room)` to subscribe; rooms are typically `owner:<ownerId>` or `admin:<adminId>`.

---

## 12. Frontend layout (`web/src/`)

- `App.tsx` — desktop / admin shell.
- `AppMobile.tsx` — mobile shell (reception / scanner / approver flows).
- `routes.tsx`, `routes.mobile.tsx` — route tables for each shell.
- `pages/` — page components.
- `components/` — shared UI.
- `hooks/` — data fetching + side-effect hooks. Realtime listeners live here.
- `store/` — client state (auth, current workspace).
- `lib/` — API client, formatters, QR helpers.

Both shells share `store/`, `hooks/`, and `lib/` — keep new shared logic there rather than duplicating per-shell.

---

## 13. Local development

### Backend

```bash
cd api
npm install
cp .env.example .env   # fill in DATABASE_URL, JWT_SECRET, SMTP creds, FCM creds
npm run prisma:generate
npm run prisma:migrate
npm run seed           # optional — seed demo workspace
npm run dev            # nodemon + ts-node, defaults to :4200
```

### Frontend

```bash
cd web
npm install
npm run dev            # Vite, defaults to :3200
```

Set `FRONTEND_URL` in `api/.env` to the Vite origin (or `*` for permissive CORS in dev only).

---

## 14. Schema change protocol

1. Edit `api/src/prisma/schema.prisma`.
2. Run `npm run prisma:migrate -- --name <topic>` locally to generate the migration file.
3. Commit the migration file with the code change in the same commit.
4. In production, CI runs `prisma migrate deploy`. **Never run `prisma db push` in production.**

The `prisma:push` script exists for local prototyping only.

---

## 15. Type-check gate

Before committing any TypeScript change, both packages must pass:

```bash
cd web && npx tsc --noEmit
cd api && npx tsc --noEmit
```

Do not skip this gate. CI runs the same check.

---

## 16. Environment variables

```env
# Backend (api/.env)
DATABASE_URL=postgresql://<user>:<pwd>@<host>/gatepass
JWT_SECRET=<random-256-bit>
PUBLIC_API_URL=https://api.ep.vcarrd.com
FRONTEND_URL=https://app.gatepass.io
EMAIL_FROM=noreply@vcarrd.com
SMTP_HOST=...
SMTP_USER=...
SMTP_PASS=...
S3_BUCKET_NAME=gatepass-assets
FCM_PROJECT_ID=...
FCM_CLIENT_EMAIL=...
FCM_PRIVATE_KEY=...
VAPID_PUBLIC_KEY=...
VAPID_PRIVATE_KEY=...
VAPID_SUBJECT=mailto:noreply@vcarrd.com

# Frontend (web/.env)
VITE_APP_NAME=Gate Pass
VITE_API_URL=https://api.ep.vcarrd.com
VITE_VAPID_PUBLIC_KEY=...
```

Do not use old domain strings, old bucket names, or old database names in new config or docs.

---

## 17. Branch & commit conventions

- All work goes to the `gatepass` branch.
- Commit format: `gatepass: <comma-separated topics>`
- Example: `gatepass: add occurrence filter to visitor list, fix scan log attribution`

---

## 18. Common implementation pitfalls

- **Cross-tenant leak** — always filter by `ownerId`. Adding a "global" feature for the platform console? Use a `PlatformAdmin` route under `/api/platform` instead.
- **Wrong actor on audit** — `logActivity` must read `req.actorEmail`. Never substitute the owner email when a sub-admin made the change.
- **Forgetting frequent-visitor branches** — auto-checkout, auto-cancel, approval expiry, and "already arrived" all special-case `isFrequent`. Adding a new sweep? Decide explicitly whether frequent visitors are included.
- **QR exposure** — public URLs (walk-in form, decision link, visitor QR) use opaque tokens (`WalkInQR.code`, `Visitor.shortId`, `Visitor.decisionToken`). Never embed primary `id`s in a public URL.
- **`assignedApproverId` vs `assignedAdminId`** — new writes go to `assignedAdminId`. Keep both readable until the legacy column is dropped.
- **Email template rendering** — `designJson` null = legacy plain-text path. New templates should always populate both `designJson` and the exported `body` HTML.

---

*This guide reflects the codebase as of 2026-06-11. Update sections whenever the schema, route map, or cron behavior changes.*
