diff --git a/.env.example b/.env.example
index 19dee58..1c79749 100644
--- a/.env.example
+++ b/.env.example
@@ -11,6 +11,9 @@ PLATFORM_BASE_URL="http://localhost:3000"
PORTAL_BASE_URL="http://localhost:3000"
DEFAULT_COMPANY_SLUG="acme-realty"
ESTATEOS_ENABLE_DEV_BYPASS="false"
+# CSP escape hatch: "true" ships Content-Security-Policy-Report-Only instead of
+# the enforced header (see src/lib/security/csp.ts). Leave false in production.
+ESTATEOS_CSP_REPORT_ONLY="false"
# Safety guard. Set these to your live production database's Supabase project
# ref and pooler host. When a non-production runtime's DATABASE_URL/DIRECT_URL
# points at this database, dev bypass is force-disabled and demo/seed writes
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..509fa24
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,53 @@
+# Continuous integration — runs the same gate as `npm run check` on every PR
+# and every push to main: tests, typecheck, lint, and a production build.
+#
+# Notes:
+# - The build intentionally runs WITHOUT secrets: scripts/run-build.mjs is
+# designed so builds never require database access, and src/lib/config.ts
+# treats all integration env vars as optional (features degrade gracefully).
+# Database migrations are a separate, controlled release step
+# (`npm run db:migrate:deploy`), never part of CI.
+# - `npm run encoding:check` is NOT included: it currently fails on many
+# pre-existing files (em dashes etc.). Add it here after a cleanup pass.
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ check:
+ name: Test, typecheck, lint, build
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+
+ # postinstall runs `prisma generate`, so the client exists for tests
+ # and the typecheck without any database connection.
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Tests
+ run: npm run test
+
+ - name: Typecheck
+ run: npm run typecheck
+
+ - name: Lint
+ run: npm run lint
+
+ - name: Build
+ run: npm run build
diff --git a/COMMIT-PROMPT.md b/COMMIT-PROMPT.md
deleted file mode 100644
index 991ce38..0000000
--- a/COMMIT-PROMPT.md
+++ /dev/null
@@ -1,47 +0,0 @@
-# Prompt for Claude Code — commit & push all updates
-
-Copy everything in the fenced block below and paste it into Claude Code, run from the
-repository root (`C:\Users\HP\Desktop\Realestate saas`).
-
-```
-Commit and push all current work in this repository. Follow these steps carefully and stop if any gate fails.
-
-CONTEXT
-- Branch should be `feat/design-system-v2`, tracking `origin/feat/design-system-v2`.
-- There is a large amount of uncommitted work (~120 modified files, ~70 new files), including:
- - Prisma migrations 0042–0047 (site-content CMS, team staff-code unique, front-desk visitor/call log, property invoices, in-app messaging, announcements).
- - New features: in-app messaging, announcements/broadcast banner, property invoices, front-desk logbook + dashboard, CEO executive overview, finance board, reservations board, site-content CMS.
- - Design-system v2: tokens, dark mode, page transitions, command palette, loading/empty primitives, scroll-aware header, marketing/tenant redesigns.
- - WhatsApp click-to-chat: a shared `WhatsAppButton` wired into clients, front-desk visitor log, leads/inquiries, inspections, schedule, team, transactions, and payment requests. Two of these (transactions, payment requests) also plumb a `buyerPhone` field through the queries in `src/modules/admin/operations.ts` and `src/modules/admin/control-center.ts`.
-
-STEPS
-1. Run `git status` and `git rev-parse --abbrev-ref HEAD`. Confirm the branch is `feat/design-system-v2`. If it is not, stop and tell me before doing anything else.
-2. Make sure no stray build artifacts are staged or present: `tc.out`, `tc_result.log`, `tsconfig.scope.json`, or any files matching `_wa_*`, `_cav*`, `_orig*`, `*_test.tsx` scratch files. If any exist, delete them. (`RECAP.md` is intentional — keep it.)
-3. Run the full verification gate: `npm run check`. This runs test + typecheck + lint + build. Do NOT commit if it fails — instead, show me the failing output and stop.
-4. If the check passes, stage everything: `git add -A`.
-5. Show me `git status` and `git diff --cached --stat` so I can see exactly what will be committed.
-6. Commit with this message (a single commit is fine):
-
- feat: WhatsApp click-to-chat across operator surfaces + session feature set
-
- - Add shared WhatsAppButton (wa.me click-to-chat, no wallet/credentials) and wire it into
- clients, front-desk visitor log, leads/inquiries, inspections, schedule, team,
- transactions, and payment requests. Plumb buyerPhone through transactions and
- payment-request queries (company-guarded).
- - In-app messaging, announcements/broadcast banner, property invoices, front-desk
- logbook + dashboard, CEO executive overview, finance board, reservations board,
- site-content CMS (migrations 0042–0047).
- - Design-system v2: tokens, dark mode, page transitions, command palette, loading/empty
- primitives, scroll-aware header, marketing + tenant redesigns.
-
-7. Push: `git push origin feat/design-system-v2`. If the branch has no upstream yet, use `git push -u origin feat/design-system-v2`.
-8. Report the final commit hash and confirm the push succeeded.
-
-Do not open a pull request unless I ask. Do not rebase or force-push.
-```
-
-## Notes
-- The database-touching tests only pass on your Windows machine (Prisma Client is generated
- for Windows). That's why `npm run check` is the right gate to run there.
-- Migrations 0046 and 0047 were already applied per the session recap; committing them just
- records the migration files in git.
diff --git a/DEPLOYMENT-CHECKLIST.md b/DEPLOYMENT-CHECKLIST.md
new file mode 100644
index 0000000..da65a06
--- /dev/null
+++ b/DEPLOYMENT-CHECKLIST.md
@@ -0,0 +1,102 @@
+# Deployment Checklist — Modernization batch (2026-07-06)
+
+Covers everything shipped in this session series: enforced CSP, grouped nav,
+UI primitives + DataTable migrations, Select/Dialog unification, system-aware
+dark mode, PWA, CI, next/image for R2 media, conditional-polling realtime,
+webhook idempotency hardening, buyer bottom tab bar — plus the pre-existing
+Users tab + MARKETER role batch that ships with it.
+
+---
+
+## 1 · Ship the code
+
+- [ ] `npm run check` green locally (already confirmed).
+- [ ] Commit & push via `COMMIT-PROMPT.md` (Claude Code).
+- [ ] Open PR `feat/design-system-v2` → `main`. The new GitHub Actions CI
+ (`.github/workflows/ci.yml`) runs the same gate on the PR — its first
+ ever run, so watch it. If the env-less CI build fails on a missing
+ variable, fix the default in `src/lib/config.ts`, not with CI secrets.
+
+## 2 · Pre-deploy environment (Vercel)
+
+- [ ] `R2_PUBLIC_BASE_URL` is present in the **build** environment (not just
+ runtime) — the image-optimizer host allowlist is baked at build time.
+- [ ] `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN` present
+ (conditional polling + rate limits).
+- [ ] `ESTATEOS_CSP_REPORT_ONLY` is **unset or "false"** — CSP enforces.
+- [ ] Paystack/Clerk/R2/Resend secrets unchanged — nothing in this batch
+ rotates credentials.
+
+## 3 · Database migrations (before deploying app code)
+
+Run against production per the runbook (`PROD-MIGRATION-RECONCILIATION.md`):
+
+- [ ] `npm run db:migrate:deploy` applies:
+ - `0048_marketer_role` (AppRole MARKETER — already applied to dev)
+ - `20260701000111_…` (reconciliation placeholder)
+ - `0049_webhook_event_dedup_unique` — deletes historical duplicate
+ webhook rows (keeps oldest per event id), then adds the unique index
+ on `WebhookEvent(companyId, provider, providerEventId)`. Additive and
+ safe to run while the old code is live.
+- [ ] Verify: `SELECT indexname FROM pg_indexes WHERE tablename='WebhookEvent';`
+ shows `WebhookEvent_companyId_provider_providerEventId_key`.
+
+## 4 · Deploy
+
+- [ ] Merge to `main`; let Vercel build and promote.
+- [ ] Build log: confirm `prisma generate` + `next build` complete and the
+ route list includes `/manifest.webmanifest` and `/api/realtime/version`.
+
+## 5 · Post-deploy verification (15 minutes, in order)
+
+**CSP (highest risk — first enforced deploy)**
+- [ ] Open the admin dashboard and buyer portal with DevTools console open:
+ zero CSP violation errors.
+- [ ] Sign out/in (Clerk widget loads), open a property page (Mapbox tiles),
+ open an uploaded image/receipt (R2), start a test Paystack checkout
+ (iframe loads). All are CSP-sensitive surfaces.
+- [ ] Rollback lever: set `ESTATEOS_CSP_REPORT_ONLY=true` + redeploy →
+ instantly back to report-only.
+
+**Payments (idempotency hardening)**
+- [ ] Make one real/demo payment end-to-end: webhook reconciles, receipt
+ generated, balance decremented once, buyer email + WhatsApp sent once.
+- [ ] Paystack Dashboard → resend the same webhook event: response is
+ `duplicate: true`, no second receipt, balance unchanged.
+
+**Realtime (conditional polling)**
+- [ ] Two browser tabs (admin + portal): create a lead or payment; both
+ surfaces refresh within ~15 s.
+- [ ] Network tab shows `/api/realtime/version` every 15 s returning
+ `{ enabled: true, version: n }`.
+
+**Images**
+- [ ] Property photos load via `/_next/image?...` (network tab) with
+ `content-type: image/avif` or `webp` — not full-size originals.
+
+**PWA + mobile**
+- [ ] `/manifest.webmanifest` resolves; Android Chrome offers install; icon
+ and splash look right.
+- [ ] On a phone: buyer portal shows the bottom tab bar (badges work), the
+ drawer still opens, dark mode follows system on first visit.
+
+**Nav + tables**
+- [ ] Sidebar shows grouped sections for each role (check a STAFF or FINANCE
+ user, not just ADMIN); collapse state persists across reloads.
+- [ ] Payments / Invoices / Contracts / Marketers tables sort, search, and
+ paginate; contract "Send to buyer" and "Regenerate" actions work.
+
+## 6 · Watch for 48 hours
+
+- [ ] Sentry: no new error signatures (especially CSP-adjacent or webhook).
+- [ ] Upstash: command volume roughly `dashboards × 4/min` — flat and tiny.
+- [ ] Vercel: image-optimization usage rises modestly (expected trade for
+ the bandwidth win); function duration stable.
+- [ ] Paystack webhook logs: 200s, no retry storms.
+
+## Rollback levers (in escalation order)
+
+1. CSP only: `ESTATEOS_CSP_REPORT_ONLY=true` + redeploy.
+2. App: Vercel → promote previous deployment.
+3. Migration 0049 is additive — safe to leave in place even when rolling the
+ app back; old code simply doesn't rely on the index.
diff --git a/next.config.ts b/next.config.ts
index 5fb6e09..059bca1 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,70 +1,32 @@
import type { NextConfig } from "next";
/**
- * Content-Security-Policy — REPORT-ONLY (not enforced).
+ * Content-Security-Policy is ENFORCED and lives in src/proxy.ts +
+ * src/lib/security/csp.ts (a static header cannot carry a per-request nonce,
+ * so it moved out of this file). The previous report-only phase (2026-06)
+ * informed the allowlist there. Only nonce-free security headers remain below.
*
- * This is intentionally shipped as `Content-Security-Policy-Report-Only` so the
- * browser REPORTS violations (visible in DevTools → Console / Network) WITHOUT
- * blocking anything. Nothing here can break the live app. The goal is to observe
- * what real traffic actually loads/connects to for a while, tune this allowlist
- * to remove anything unused and add anything missing, and only THEN switch the
- * header key to `Content-Security-Policy` to enforce it.
- *
- * Allowlist is derived from the third-party services the app actually uses:
- * - Clerk (auth UI + FAPI + Cloudflare Turnstile bot challenge)
- * - Paystack (inline checkout script + API + checkout iframe)
- * - Mapbox GL JS (bundled script; loads tiles/styles + uses blob workers)
- * - PostHog (custom fetch client → NEXT_PUBLIC_POSTHOG_HOST/capture/)
- * - Sentry (bundled SDK; sends events to *.ingest.sentry.io)
- * - Cloudflare R2 (browser presigned PUT/GET to *.r2.cloudflarestorage.com)
- * - Unsplash (marketing imagery)
- * - Vercel (vercel.live preview toolbar on preview deployments)
- * Server-only integrations (Gemini, Resend, Twilio) never touch the browser, so
- * they need no CSP entries.
- *
- * KNOWN LOOSENESS to tighten before enforcement:
- * - script-src includes 'unsafe-inline' and 'unsafe-eval'. Next.js/React inject
- * inline bootstrap scripts and this config cannot emit a per-request nonce
- * (static headers only). Before enforcing, move to a nonce-based script-src
- * with 'strict-dynamic' (requires middleware) and drop 'unsafe-eval'.
- * - style-src includes 'unsafe-inline' for Tailwind/inline styles + Mapbox.
- *
- * NOTE: if the production Clerk instance uses a custom FAPI domain
- * (e.g. https://clerk.your-domain.com), add it to script-src and connect-src.
+ * Escape hatch: ESTATEOS_CSP_REPORT_ONLY=true reverts to a report-only header.
+ */
+/**
+ * Host of the public R2 media domain (custom domain or *.r2.dev), derived at
+ * build time from R2_PUBLIC_BASE_URL. Used twice:
+ * - added to images.remotePatterns so next/image may optimize R2 media;
+ * - inlined into the client bundle (NEXT_PUBLIC_R2_PUBLIC_HOST) so
+ * shouldUseUnoptimizedImage() can whitelist the same host.
+ * Presigned *.r2.cloudflarestorage.com URLs are intentionally NOT optimized:
+ * their signatures change per request (cache-busting) and can expire before
+ * the optimizer fetches them.
*/
-const contentSecurityPolicyReportOnly = [
- // Lock everything down by default; specific resource types are opened up below.
- "default-src 'self'",
- // Scripts: self + inline/eval (see looseness note) + external SDK script hosts.
- "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.clerk.accounts.dev https://*.clerk.com https://challenges.cloudflare.com https://js.paystack.co https://vercel.live",
- // Some browsers consult script-src-elem/attr separately; keep them aligned.
- "script-src-elem 'self' 'unsafe-inline' https://*.clerk.accounts.dev https://*.clerk.com https://challenges.cloudflare.com https://js.paystack.co https://vercel.live",
- // Styles: Tailwind + inline style attributes + Mapbox-injected styles.
- "style-src 'self' 'unsafe-inline'",
- "style-src-elem 'self' 'unsafe-inline'",
- // Images: self, data/blob URIs, Unsplash, R2 media, Clerk avatars, Mapbox tiles.
- "img-src 'self' data: blob: https://images.unsplash.com https://*.r2.cloudflarestorage.com https://img.clerk.com https://*.clerk.com https://api.mapbox.com https://*.tiles.mapbox.com",
- // Fonts: self-hosted + data URIs (no Google Fonts CDN is used).
- "font-src 'self' data:",
- // XHR/fetch/websocket targets: own origin + every third-party API the browser calls.
- "connect-src 'self' https://*.clerk.accounts.dev https://*.clerk.com https://clerk-telemetry.com https://api.paystack.co https://api.mapbox.com https://events.mapbox.com https://*.tiles.mapbox.com https://*.posthog.com https://*.i.posthog.com https://*.sentry.io https://*.ingest.sentry.io https://*.ingest.us.sentry.io https://*.ingest.de.sentry.io https://*.r2.cloudflarestorage.com https://vercel.live wss://ws-us3.pusher.com",
- // Iframes the app embeds: Clerk components, Cloudflare Turnstile, Paystack checkout, Vercel toolbar.
- "frame-src 'self' https://*.clerk.accounts.dev https://challenges.cloudflare.com https://checkout.paystack.com https://*.paystack.com https://vercel.live",
- // Web/Service workers (Mapbox GL spawns blob: workers).
- "worker-src 'self' blob:",
- "child-src 'self' blob:",
- // Media (audio/video) — self + blob + presigned R2 objects.
- "media-src 'self' blob: https://*.r2.cloudflarestorage.com",
- // App manifest.
- "manifest-src 'self'",
- // Form submissions only to our own origin (+ Paystack redirect target, defensively).
- "form-action 'self' https://checkout.paystack.com",
- // Who may frame us — mirrors the existing X-Frame-Options: SAMEORIGIN header.
- "frame-ancestors 'self'",
- // Restrict
| - {h} - | - ))} -
|---|
| {column} | - ))} -||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| #{row.rank} | -
-
-
-
-
- {row.fullName}
- {row.title}
-
- {!row.isActive ? Inactive : null}
- {!row.isPublished ? Private : null}
-
- |
- {row.score} | -{row.starRating.toFixed(1)} | -{formatCurrency(row.revenue.weekly)} | -{formatCurrency(row.revenue.monthly)} | -{formatCurrency(row.revenue.lifetime)} | -
- {formatCurrency(row.commissionTotal)}
- {row.commissionPending > 0 && (
-
- {formatCurrency(row.commissionPending)} pending
-
- )}
- |
- {row.metrics.completedDeals} | -{row.metrics.successfulPayments} | -{row.metrics.inspectionsHandled} | -{row.metrics.reservations} | -{trendLabel(row.trend)} | -
| {column} | - ))} -|||||||
|---|---|---|---|---|---|---|---|
| {row.reference} | -{row.buyer} | -{row.marketer} | -{row.paymentStatus} | -{row.stage} | -{row.outstandingBalance} | -{row.nextDueAt} | -- {row.receiptId ? ( - - Download - - ) : ( - "Pending" - )} - | -
Leave as "All" for a company-wide default.