# 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.