# Website ↔ CRM cutover runbook

This document captures the agreed plan (per the 2026-05-09 audit, Q6) for
moving the marketing website off the legacy NocoDB Berths table and onto
the CRM as the source of truth. Decision: **double-write transition
window** — both feeds stay live for ~30 days, then NocoDB is decommissioned.

The CRM side is fully wired today. Most outstanding work lives in the
**website repo**.

---

## Endpoints involved

### Public berth feed (replaces NocoDB Berths read path)

- `GET /api/public/berths` — list (NocoDB-verbatim shape; see
  `src/lib/services/public-berths.ts`)
- `GET /api/public/berths/[mooringNumber]` — single
- Cache: `s-maxage=300, stale-while-revalidate=60` (5 min)
- Status mapping: `Sold` > `Under Offer` > `Available`

### Public inquiry intake (replaces NocoDB inquiry write path)

- `POST /api/public/website-inquiries` — accepts inquiry form submissions
  from the marketing site
- Auth: shared secret in `X-Intake-Secret` header, compared via timing-safe
  equality against `WEBSITE_INTAKE_SECRET`. Refuses every request when the
  env var is unset (correct posture for dev / staging until the website is
  also configured).

### Health endpoint (monitoring contract)

- `GET /api/public/health` — anonymous: `{status, timestamp}` (always 200,
  for uptime monitors). Authenticated with `X-Intake-Secret`: full
  `{status, env, appUrl, timestamp, checks: {db, redis}}` payload, returns
  503 when any dependency is down. The website calls the authenticated
  variant on startup so it refuses to boot when its `CRM_PUBLIC_URL`
  points at the wrong env.

---

## Pre-cutover checklist (CRM side — done)

- [x] `/api/public/berths` serves Map Data (117 rows backfilled
      2026-05-09).
- [x] PublicBerth payload exposes verbatim NocoDB fields, plus
      booleans / metric variants / timestamps (commit `72ab718`). Price
      intentionally omitted (decision Q4).
- [x] `/api/public/website-inquiries` POST handler exists, gated on
      `WEBSITE_INTAKE_SECRET`.
- [x] `WEBSITE_INTAKE_SECRET` documented in `.env.example`.

## Pre-cutover checklist (website repo — owed)

- [ ] Generate a strong shared secret (`openssl rand -hex 32`) and set
      `CRM_INTAKE_SECRET` (website) **and** `WEBSITE_INTAKE_SECRET` (CRM)
      to the same value in production.
- [ ] Wire the website's berth-map fetch to `${CRM_PUBLIC_URL}/api/public/berths`.
      Keep the existing NocoDB fetch in parallel for the transition window.
- [ ] Wire the website's inquiry submit handler to `POST` to
      `${CRM_PUBLIC_URL}/api/public/website-inquiries` with the
      `X-Intake-Secret` header. Keep the existing NocoDB write in parallel.
- [ ] Add a startup probe to `${CRM_PUBLIC_URL}/api/public/health`
      (authenticated) so the website fails fast on misconfigured env.

## Double-write window (target: 30 days)

During the window:

1. Marketing site reads from BOTH feeds for any change-detection or
   reconciliation jobs (or just CRM if reads can flip atomically).
2. Marketing site writes inquiries to BOTH NocoDB and CRM. The CRM
   surface is treated as authoritative for triage; NocoDB stays as a
   passive backup so the rollback path is one DNS / env flip away.
3. Berth status edits made in CRM are NOT synced back to NocoDB.
   NocoDB will progressively go stale — accepted because the website is
   already preferring the CRM read. NocoDB stays usable as a snapshot of
   pre-cutover state.
4. Daily sanity check: `curl -s ${CRM_PUBLIC_URL}/api/public/berths | jq '.pageInfo'`
   — confirms the public feed still serves and the row count matches
   expectations (117 berths in port-nimara).

## Cutover steps (target: ~Day 30)

1. Stop the NocoDB-side writes from the website (drop the dual write).
2. Stop the NocoDB-side reads from the website (CRM-only).
3. Mark the NocoDB Berths table read-only via NocoDB ACL.
4. Wait 7 days; if no one notices anything missing, drop the NocoDB
   Berths table and revoke the NocoDB MCP token from `~/.claude.json`.

## Rollback path

The double-write design means rollback within the 30-day window is a
single env / DNS flip:

- Website: change `CRM_PUBLIC_URL` to the old NocoDB-fronted URL OR
  toggle a feature flag back to NocoDB.
- CRM: no change required — the public endpoints stay live for any
  consumer that didn't roll back.

After NocoDB is decommissioned, rollback requires restoring the table
from backup. That's the trade-off for the cleaner final state.

---

## Open follow-ups

- **Berth `archived_at`** — when retiring a berth, the public feed will
  still serve it. Add a soft-delete column + filter on
  `/api/public/berths` before any berth is permanently removed. (Not
  blocking the cutover; flagged in the audit.)
- **CRM-edit drift vs re-imports** — `scripts/import-berths-from-nocodb.ts`
  skips rows where `updated_at > last_imported_at`. After cutover the
  website MUST stop writing to NocoDB; if any straggler write hits
  NocoDB and someone re-runs the import script, those edits would
  silently win over CRM data. Mitigation: the script is opt-in, and the
  `updated_at` guard means a full re-import only overwrites when the
  rep explicitly passes `--force`. Decommission the script once cutover
  is irreversible.
- **5-minute cache** — `s-maxage=300` on `/api/public/berths` means a
  CRM-side status flip won't show on the website for up to 5 minutes.
  Acceptable for marketing; bump if marketing wants near-real-time
  updates.
