# Deploy notes — visitor_demo

Production-deploy checklist for the changes batched in the visitor_demo branch.
Read top-to-bottom and run the steps in order. The whole deploy is **zero-downtime
and reversible**; nothing on this list requires a maintenance window.

---

## What's in this release

- Visitor lifecycle: manual + auto check-out, auto-cancel sweep for no-show
  invites, new `CHECKED_OUT` status, `checkedOutAt` timestamp on Visitor.
- Walk-in policy on **Add Visitor** now fires host approval immediately on
  save — no QR scan step in between. The checkpoint-QR walk-in flow is
  unchanged.
- Per-owner cron timings (auto-checkout, auto-cancel, reminder lead) tunable
  from Owner Settings → "Automation timings".
- Approvals tab gains Cancelled / Expired / Checked out filter pills.
- Visitor details modal shows Arrived + Checked out timestamps.
- Notification copy rewritten end-to-end for clarity.
- **Web Push notifications**: OS-tray notifications that fire even when no
  tab is open. Service worker handles delivery + click deep-links to
  `/visitors` (approvals), `/visitor-scanner`, etc. Requires new VAPID
  env vars on the API (see step 2 below).

---

## Deploy order

> Run these three steps in sequence. The system stays serving traffic
> throughout; we just want columns to exist before code reads them.

### 1. Apply the database migration

From `apps/api/`:

```bash
npx prisma migrate deploy --schema=src/prisma/schema.prisma
```

The relevant new migrations are:

| Migration | Purpose |
|---|---|
| `20260526100000_visitor_checked_out_at` | Adds `Visitor.checkedOutAt` (nullable). |
| `20260526110000_visitor_status_checked_out` | Adds `CHECKED_OUT` to the `VisitorStatus` enum + backfills `ARRIVED` rows with non-null `checkedOutAt`. |
| `20260526120000_owner_automation_timings` | Adds `Owner.autoCheckoutTime`, `autoCancelTime`, `reminderHoursBefore`, `lastAutoCheckoutAt`, `lastAutoCancelAt`. Seeds last-run stamps to NOW() for every existing Owner. Grandfathers `canPolicyWalkIn = TRUE` for every Admin who had `canPolicyPre = TRUE`. |
| `20260526130000_push_subscription` | Adds `PushSubscription` table (Web Push subscriptions per browser). Empty on deploy; populated as users opt in via the Settings prompt. |

All migrations use `ADD COLUMN IF NOT EXISTS` and similar guards. Safe to re-run.

**Verify** the new Owner columns landed and the backfill ran:

```sql
SELECT id,
       "autoCheckoutTime",
       "autoCancelTime",
       "reminderHoursBefore",
       "lastAutoCheckoutAt",
       "lastAutoCancelAt"
FROM "Owner"
LIMIT 5;
```

Every existing Owner row should have non-null `lastAutoCheckoutAt` /
`lastAutoCancelAt` (today's timestamp). If they're NULL, re-run the
migration — the `UPDATE` statements at the end of the SQL file weren't
applied.

### 2. Generate VAPID keys and add env vars (one-time)

Web Push needs a server-side key pair. Generate fresh **production** keys —
do NOT reuse the dev keys in the repo's `.env`. From `apps/api/`:

```bash
node -e "const wp = require('web-push'); const k = wp.generateVAPIDKeys(); console.log('VAPID_PUBLIC_KEY=' + k.publicKey); console.log('VAPID_PRIVATE_KEY=' + k.privateKey);"
```

Append the output to the production `apps/api/.env`:

```
VAPID_PUBLIC_KEY=<the public key from above>
VAPID_PRIVATE_KEY=<the private key from above>
VAPID_SUBJECT=mailto:noreply@yourdomain.com
```

`VAPID_SUBJECT` is required by the Web Push spec — Mozilla/Google/Apple
contact you at it if their push service has an issue with your traffic.
Use a real mailbox you read.

**Don't rotate VAPID keys casually.** Every existing browser subscription
is bound to a specific `applicationServerKey`; rotating invalidates them
all and every user has to re-grant permission.

### 3. Install web-push (one-time, if doing a fresh `npm install`)

`apps/api/package.json` now depends on `web-push@^3.6.7`. A regular
`npm install` on the prod box picks it up. If you're using a pre-built
artifact, ensure the dependency is in `node_modules/`.

### 4. Deploy the API

Standard pull + rebuild + pm2 restart (or whatever you use for `apps/api`).
Watch the boot log for one of:

- `[cron] started — tick every 30 minutes (auto-checkout + auto-cancel + reminders, per-owner)` → all good.
- `[cron] owner.automation columns missing — apply 20260526120000 migration to enable cron` → step 1 didn't apply. API is still up serving everything else; just apply the migration and the cron self-heals on the next 30-min tick.

If VAPID env vars are missing, the API logs `[webpush] VAPID keys missing — Web Push disabled` once on the first push attempt and keeps serving everything else; only Web Push fan-out is skipped. Mobile FCM and socket bell still work.

### 5. Deploy the Web

Standard pull + `npm run build` + serve the dist. **`apps/web/public/service-worker.js` must be served from the origin root** (`https://your-domain/service-worker.js`). The Vite build copies everything under `public/` to the dist root automatically, so as long as your server serves `dist/` from `/`, you're fine.

> **HTTPS is mandatory.** Browsers refuse to register a service worker on
> plain HTTP (except `localhost`). If your prod site is behind a reverse
> proxy that terminates TLS, the `service-worker.js` request must still
> resolve over `https://`.

If the API doesn't yet have `/api/automation-timings` or `/api/push/*` mounted, the new Settings card and the push subscription silently no-op (404 swallowed) — so step 5 before step 4 is also safe.

---

## What happens on day 1 vs day 2

The migration backfills `lastAutoCheckoutAt = NOW()` and `lastAutoCancelAt = NOW()`
for every existing Owner. Because the cron's `shouldRunToday()` short-circuits
when last-run-date equals today, **both nightly sweeps are suppressed on
deploy day**.

- **Day 1 (deploy day)**: cron ticks every 30 minutes, but neither sweep
  fires for any existing workspace. New Owners created after the deploy have
  NULL stamps and run on their first qualifying tick — same as designed.
- **Day 2 (00:00 + 00:01 local server time)**: first real auto-checkout
  sweeps stale ARRIVED rows from day 1; first real auto-cancel sweeps
  EXPECTED / AWAITING_APPROVAL rows whose `visitDate` was day 1.

That 24h grace lets you watch the cron tick logs before any production
data moves.

### Auto-cancel lookback

Even when it runs, the auto-cancel sweep is **capped at the last 7 days**.
Rows older than 7 days that are still EXPECTED / AWAITING_APPROVAL stay
untouched — they're treated as historical and the owner can clean them
manually if they want. Steady-state, this still catches every yesterday
no-show.

The 7-day window is hard-coded right now
(`AUTO_CANCEL_LOOKBACK_DAYS` in `apps/api/src/lib/reminderCron.ts`). Bump
it if you want a wider net.

---

## Rollback

The release is engineered so that a code-only rollback is enough — the
DB schema is purely additive.

- **Web rollback**: redeploy the previous build. The new columns the
  previous code never touched remain in the DB harmlessly.
- **API rollback**: redeploy the previous build. The old code doesn't
  query the new Owner columns; it doesn't query `Visitor.checkedOutAt`;
  it doesn't know about `CHECKED_OUT` enum value (but no rows have that
  status yet unless you let day-2 run). If you also want to clear the
  `CHECKED_OUT` rows:
  ```sql
  UPDATE "Visitor" SET status = 'ARRIVED', "checkedOutAt" = NULL
  WHERE status = 'CHECKED_OUT';
  ```
  (Re-applying the visitor-status-checked_out migration is a no-op after
  this — the enum value stays.)
- **DB rollback** is not necessary, but if you really want to drop the
  new columns:
  ```sql
  ALTER TABLE "Owner"
    DROP COLUMN IF EXISTS "autoCheckoutTime",
    DROP COLUMN IF EXISTS "autoCancelTime",
    DROP COLUMN IF EXISTS "reminderHoursBefore",
    DROP COLUMN IF EXISTS "lastAutoCheckoutAt",
    DROP COLUMN IF EXISTS "lastAutoCancelAt";
  ALTER TABLE "Visitor" DROP COLUMN IF EXISTS "checkedOutAt";
  DROP TABLE IF EXISTS "PushSubscription";
  ```
  Don't bother dropping the enum value — Postgres doesn't support it
  without a table rewrite and it costs nothing to leave in place.
- **Web Push specifically**: clear the env vars to disable the dispatcher
  without changing code (`unset VAPID_PUBLIC_KEY VAPID_PRIVATE_KEY` and
  restart). The API logs a one-time warning and keeps serving everything
  else. Existing subscriptions stay in the DB but never receive pushes
  until you restore the keys — no user-visible breakage.

---

## Smoke tests after deploy

Run these from any machine that can hit the API:

```bash
# 1. Health
curl -s https://<api-host>/api/health

# 2. Automation timings endpoint exists and is owner-gated
curl -s -o /dev/null -w "%{http_code}\n" https://<api-host>/api/automation-timings
# expect: 401 (no auth) — endpoint mounted, route works

# 3. VAPID public key endpoint serves the production key
curl -s https://<api-host>/api/push/public-key
# expect: {"publicKey":"<your prod key>"} — confirms env vars loaded
```

Then in the web UI as a workspace owner:

1. **Owner Settings** → confirm the new "Automation timings" card appears
   with the three inputs and the last-run footer.
2. **Visitors** tab → confirm an ARRIVED visitor has a "Check out" button;
   click it; confirm the row shows the "Checked out" badge and the modal's
   Visitor Details now show both Arrived and Checked out tiles.
3. **Add Visitor** → pick "Walk-in (here now)" + an approver → Save.
   Bell should flash on the host immediately (no scan needed). Approve
   from the bell — visitor flips to Arrived.
4. **Approvals** tab → confirm the new Cancelled / Expired / Checked out
   pills appear; click each — empty-state message renders cleanly.
5. **Browser notifications**:
   - Click **Enable** in the blue "Enable browser notifications" banner.
     OS prompt → Allow.
   - In DevTools → Application → Service Workers, confirm `/service-worker.js`
     is active. → Storage → IndexedDB / Push Messaging, confirm a subscription is registered.
   - Verify a row landed in DB: `SELECT email, endpoint FROM "PushSubscription" JOIN "Owner" ON "Owner".id = "PushSubscription"."ownerId";`
   - Close every tab of the app. Have a teammate (or yourself in another browser) trigger a new approval (walk-in or scan). Within a second or two, an OS-tray notification should appear. Click it → the matching tab opens to `/visitors`.

---

## Permission grandfathering — who's affected

The walk-in policy gate moved from `(canPolicyPre OR canPolicyWalkIn)`
to `canPolicyWalkIn` only. The migration's backfill (`UPDATE Admin SET
canPolicyWalkIn = TRUE WHERE canPolicyPre = TRUE`) makes this invisible
to existing sub-admins — anyone who could use walk-in yesterday can use
it today.

**New sub-admins** created after this deploy: pre-approval is gone, so
the Add Visitor card no longer offers it. Walk-in is the replacement;
toggle `canPolicyWalkIn` from Settings → Admins → edit member.

---

## Notification copy

Every push / bell notification across the visitor lifecycle was rewritten
for clarity. **No payload schema changed** — mobile clients keep working.
The strings now consistently follow:

- **Owner**: `{name} {state}` / `{Action} by {actor} · #{shortId}`
- **Host**: `{name} is at reception` / `Tap to approve · #{shortId}`
- **Checkpoint**: `{name} approved` / `Host cleared entry — let them in`

If you have custom alerting that greps for old strings (e.g. "Pre-approval
requested"), update the patterns to match the new copy.
