125 lines
5.4 KiB
Markdown
125 lines
5.4 KiB
Markdown
|
|
# 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.
|