# AIWoiz — Session Handoff

> **Status (2026-07-08, SOFT-LAUNCH WAITLIST + "global accents" homepage section — committed to `main`):** Company registration + bank account aren't done → the payment gateway can't go live, so the full signup→checkout funnel is a dead end. **Replaced the live marketing frontend with a lean waitlist site (`site-v0`) and parked the full product site (`beta-v1`), swappable by a one-line env flip.** `git mv aiw-voice-marketing → beta-v1` (parked, byte-restorable) + forked it into `site-v0` (live); both `deploy/{dev,prod}/docker-compose.yml` `marketing` services now build `context: ${AIW_SRC}/${MARKETING_PKG:-site-v0}` — **which package is live is set by `MARKETING_PKG` in `/opt/aiw/{dev,prod}/.env`, NOT git.** site-v0 strips pricing (page + homepage section + `/api/plans`), repoints every signup/login CTA → the waitlist modal / `/waitlist`, keeps the **live "talk-now" orb** (M1, real Retell call via `window.AIWWidget.open()`) + a **referral line-jump** success state (M2), and is fully GTM-instrumented (stable CTA classes + `data-analytics-id`; Clarity heatmaps as a GTM tag). Backend: **migration `0032_waitlist`** (`waitlist_signups` + `email_templates` CHECK + `waitlist_confirmed` template) + `src/modules/waitlist/` (public `POST /api/waitlist` idempotent-on-email + `GET /api/waitlist/position` + admin `GET /api/admin/waitlist`) + site-v0 same-origin proxies. Commits: `8bee68a` (waitlist, merged `14e6e55`). Plan: `.claude/plans/snappy-leaping-willow.md`. **⚠️ prod `site-v0/.env.production` still has the placeholder `NEXT_PUBLIC_MARKETING_WIDGET_TOKEN` → prod hero orb falls back to the DemoModal until a real `wt_` token is pasted + marketing rebuilt.**
>
> **Homepage "English, with global accents" section** (`d7f2e92`, merged `bc04352`, BOTH `site-v0` + `beta-v1`): a new `AccentsAndVoices` section under the hero/stats marquee — an auto-scrolling flag marquee of 20 English-speaking markets (India-first) scrolling →, and a reverse marquee of voice personas (Sales/Customer support/Calm/Energetic/Deep/Personal assistant/Friendly/Professional/Concierge) scrolling ←. Flags are **SVG** (`country-flag-icons@^1.6.20`, added to both marketing `package.json`, tree-shaken to the 20 used) — NOT emoji (emoji flags render as bare letters on Windows/Chrome). Reuses `@keyframes marqueeScroll` (voices = same keyframe `reverse`); `aria-hidden` + a visually-hidden a11y summary; `prefers-reduced-motion` disables it. `tsc` + `next build` clean; live DOM verified.
>
> **DEPLOY = marketing rebuild per env with `MARKETING_PKG=site-v0` + a one-time `migrate api` rebuild for 0032.** **⚠️ Live gotcha (2026-07-08):** the waitlist form 500s ("Something on our end is misbehaving") when `marketing` was rebuilt but `migrate` was NOT → `waitlist_signups` missing → INSERT throws (real error logged as `waitlist signup failed`). Fix = `cd /opt/aiw/<env> && docker compose build migrate api && up -d migrate api`, verify `db:status` 32/32. **Then a SECOND live bug** (2026-07-08, fixed): with 0032 applied the form still 500'd — `computePosition` passed a raw JS `Date` into a `sql\`\`` template and postgres.js can't byte-length a bare `Date` (`ERR_INVALID_ARG_TYPE … Received an instance of Date`); fixed with drizzle's typed `lte(col, date)` (→ `api` rebuild). **Same anti-pattern lurks unfixed in cold paths — `leads.service.ts` date filter, `email.service.ts:525`, `retell-webhook.service.ts:486-487` — convert to `${d.toISOString()}::timestamptz` (follow-up).** Restore beta-v1 later = flip `MARKETING_PKG=beta-v1` + rebuild `marketing` (no git merge). **Also on `main` but from a separate branch:** perf P0/P1 groundwork `b2d76b0` (`feature/perf-p0-p1-groundwork`) is **pushed but NOT merged** — user's call to merge. Full prose in CLAUDE.md "Completed Milestones".
>
> **Status (2026-06-29, PROD DEPLOYED → `371c2f6`):** Prod was found **stale at the pre-Stage-1 image** (DB at `0027`; running containers built before `0028`) even though the shared `/opt/aiw/src` checkout was already on `371c2f6` — the `db:status` "all applied, no drift" was the documented stale-image trap (it reads the journal baked into the running image, which maxed at `0027`). Rebuilt **`migrate api app widget marketing`** one-at-a-time (oversubscribed host) with disk preflight + prune between. **Migration `0028_widget_contact_sync` applied cleanly** (`journal has 29 entries; db has 28 applied; 1 pending: 0028` → `[apply]` → `Migrations complete: applied 1`); `db:status` now 29 entries (idx 0–28), 0 pending, 0 drift. Smoke green: `api.aiandhumn.com/health` 200, `widget.aiandhumn.com/aiw-voice-widget.js` 200, app + marketing Up. **This was the FIRST prod activation of the entire Contact-Capture Sync feature** (Stage 1 long-poll core + 0028, Stage 4a/4b, refinement, today's production-hardening) plus the co-browse polish, Enterprise lead-capture form, and founder photos that had accumulated on `main`. **Code is live but inert until the Retell-side wiring is in place per widget:** the `collect_contact` flow custom-function + the widget's `contact_sync_key`, and the spoken-path accuracy depends on the agent config (**en-IN**, Interruption Sensitivity **0.3**, Response Eagerness **low**, denoising **on**, backchannel **off**, digit-word boosted keywords) → **Publish** the agent. New contact env vars default safely (`AIW_CONTACT_SYNC_DISABLED=false`, `AIW_CONTACT_COLLECT_HOLD_MS=15000`); `COBROWSE_SUB_SECRET` was already set (cobrowse live since `0027`). **⚠️ DEV is behind:** the dev branch `feature/cobrowse-t0-t1` (HEAD `df59780`) does NOT contain the contact-capture Stage 0→4-hardening, founder, or Enterprise-lead work (those are `main`-only) — a dev deploy from that branch would not include them; the stale `prod-cobrowse-release` (`2d5ea99`) is fully contained in main and should not be deployed from.
>
> **Status (2026-06-29, Contact-Capture Stage 4 production-hardening — committed):** One more audit pass on Stage 1 + Stage 4 to make the spoken path production-smooth, targeting 4 bugs at once (P1 email re-ask + "No problem" mid-speech, P2 ASR garbles Indian words, P3 spoken phone misses `call_analysis`/`/leads`, **P4 separate voice from background noise**). A **103-agent audit swarm** (recon → bug-hunt → noise-lens → 3-skeptic verify → synth/critic) returned **12 verified survivors**, and caught a real miss: **my prior Stage 4 fix did NOT close P3** for the common spelled-out-digit case — ASR renders a dictated phone as number-WORDS, `looksLikePhone` needs literal digits, the widget never pings for phone, and the PCA backstop only read `call_analysis.customer_phone`, so `"Nine zero five six zero nine nine nine"` reached **no sink**. **Code shipped (6 files, +128/−13, both packages tsc clean, bundle 561.9 KB, unit-verified):** `detect.ts` new **`spokenToDigits()`** number-word→digit normalizer wired into `detectUserContact`'s phone fallback (fixes P3 in ALL sinks at once; null when no number-word so casual prose never coerces); `retell-webhook.service.ts` — PCA backstop now **coerces Number-typed phone → String** + adds a **transcript-scan fallback for phone** (recovers a spoken phone into `/leads` even when Retell's extractor misses it); `widget.ts` — `resetPillState()` clears the per-field `voicePingedKinds` latch so a legit same-field NAME re-ask can ping again; `decline.ts` — a turn carrying a real email/phone is a **correction, not a sticky decline**. **P2 + P4 are NOT widget code (audit unanimous)** — Retell dashboard MUST-DO + Publish: Language **en-IN**, Interruption Sensitivity **0.3**, Response Eagerness **low**, denoising **on**, `enable_backchannel` **false**, `boosted_keywords` += email domains + digit-words; SHOULD-DO flow change = collect AFTER the ask (kills the hot-mic noise amplifier + the last stall). **Rejected by user (do not revisit):** auto-muting the mic during the pill — defeats spoken capture (memory `feedback-no-mic-automute-pill`). **Deploy = rebuild `api` + `widget` per env + hard-refresh; no migration.** Full prose in CLAUDE.md "Completed Milestones".
>
> **Status (2026-06-28, Contact-Capture Stage 4 spoken-path refinement — committed):** Fixed the 3 spoken-path bugs the customer hit after Stage 4a/4b (`17d122c`): **P1** agent said "No problem, let's continue" *mid-email* then re-asked; **P2** ASR suddenly garbled Indian names/emails/digits; **P3** spoken phone reached neither `call_analysis` nor `/leads`. Root-caused by a **6-lens voice-AI expert swarm** (32→8→**7 verified** root causes). **Headline:** Stage 4's value-less voice ping made the spoken path structurally unable to capture email/phone — the ping fired on the FIRST ≥2-char ASR partial (`"Through alternate"`, ~8.6s into the 15s hold, before the visitor finished) and released the hold with `value:null`, so the confirm node had to re-extract from a garbled transcript. **Live diagnostic confirmed** email = `voice/received:true` (branch c) + phone = `timed_out/received:false`. **P2 was NOT widget code** (widget runs no ASR) — a Retell agent-config regression: Language `en-US` (→ set `en-IN`), **Interruption Sensitivity 0.9 (→ 0.3)**, **Response Eagerness 1/max (→ low)** — the agent endpointed the visitor at the first pause, chopping the spelled email into partials. **Code shipped (3 files + bundle, typecheck clean, 561.8 KB):** widget — email/phone no longer fire the value-less ping (typed pill authoritative), `name` ping debounced until the turn settles (`VOICE_PING_SETTLE_MS=1100`); `sessions.routes.ts` — voice branch parses a clean email/phone, carries the real value + writes `session_captures` so spoken values reach `/leads`; `retell-webhook.service.ts` — new `backfillCapturesFromAnalysis` on `call_analyzed` upserts `customer_*` from `call_analysis` into `session_captures` (dedup-guarded, P3 safety net). **Skipped** the P2 regression test (no test runner). **Dashboard companion (recommended):** move `collect_contact` to fire AFTER the ask (kills the 15s hold + interrupt storm), greeting-free hub, spell-back confirm, `received:false` edge must Continue. **Deploy = rebuild `api` + `widget` per env + hard-refresh; no migration.** PCA backstop needs the agent's post-call-analysis schema to define `customer_name/email/phone`. Full prose in CLAUDE.md "Completed Milestones".
>
> **Status (2026-06-17, rebrand → Asqvox):** Company renamed **AI&Humn → Asqvox** — **visible brand only** (name, logo, favicons, OG image, legal entity `Asqvox Technologies Pvt. Ltd.`). **Domains/infra deliberately untouched** (`aiandhumn.com` / `aiworkfllow.com` / nginx / OAuth / SMTP / env URLs all stay; `asqvox.com` migration is a separate future effort). 86 files: new wordmark SVG over the 3 `aih-logo.svg` (filename kept, zero import churn) + legacy dead SVGs; full favicon set + `og-default.png` + webmanifest regenerated/distributed across marketing/app/admin; keystone text in `aiw-voice-marketing/lib/site.ts` + page copy/metadata/JSON-LD, SPA, widget "Powered by Asqvox" footer (bundle rebuilt), admin title, seed, `SMTP_FROM` display. KEPT: all domains/emails/`@aiandhumn` social handles, historical migration SQL (not edited). Verified: all 4 packages tsc clean, SPA + marketing builds green, repo-wide grep shows zero brand display strings left. Full detail in CLAUDE.md "Completed Milestones". Deploy = rebuild marketing+app+widget per env; hard-refresh / re-scrape social cards for favicon + OG. Landed on `feature/cobrowse-t0-t1` (dev), replicated to `main` (prod).

> **Status (2026-06-13, host headroom remediation):** Tackled the oversubscribed VPS that caused the shim-crash incident. Confirmed RAM was **never** the issue (2.9 GB free at idle; Docker stacks ~580 MB, rest is the cPanel mail/security stack). Removed the three actual failure modes: **idled the dev stack** (`cd /opt/aiw/dev && docker compose stop` — frees ~250 MB + kills dev↔prod build contention; stays down across reboots, so `docker compose start` before any dev work; `*.aiworkfllow.com` 502 while idle), **retired n8n** (`n8n-n8n-1` + `n8n-postgres`, `docker update --restart=no` + stop, volumes kept), and added **Docker log rotation** (`/etc/docker/daemon.json`: json-file, max-size 10m, max-file 3 → 30 MB/container cap; arm with one `systemctl restart docker`, applies on container recreate). Disk reclaimed **92% → 83%** (truncated the api's 168 MB crash log + `docker builder prune -f`). Verdict: don't add RAM; disk growth is client `/home` (39 G), so resize the 80 G disk only when `/home` nears the ceiling. Logged in CLAUDE.md "Completed Milestones" + memory `ops-vps-build-shim-crash`.
>
> **Status (2026-06-13, widget fix — host-CTA `open()` regression):** **The prod marketing homepage voice CTAs ("Try this" on Chat-is-dead, the LiveDemo orb, the UseCases `voice-rings__orb`) were dead — clicking them didn't start the voice widget.** Root cause: the anti-abuse Phase 1.1 gate on `window.AIWWidget.open()` checked `inTrustedClickHandler`, a flag set only inside the widget's OWN launcher handler (which calls `startCall()` directly, never `open()`) — so every host-page `.open()` call was rejected, breaking the documented public API for ALL host sites (the corner launcher orb kept working as it's the launcher's own trusted path). Fix in `aiw-voice-widget/src/index.ts`: gate `open()` on `navigator.userActivation.isActive` (real gesture) OR the legacy flag — restores host CTAs, still blocks console/programmatic/synthetic-click abuse. Marketing code was correct all along (`onTry`/`onOpenDemo` → `handleOrbClick` → `AIWWidget.open`), so **no marketing change**. **Deploy = rebuild the WIDGET bundle only** — `cd /opt/aiw/<env> && docker compose build widget && docker compose up -d --no-deps widget` (small esbuild, low disk impact; no marketing/app rebuild). Widget typecheck + build clean; tracked `dist/aiw-voice-widget.js` rebuilt. Logged in CLAUDE.md "Completed Milestones".
>
> **Status (2026-06-13, RESOLVED — host incident, root cause + resolution):** **Prod + dev `api` were down — `502 Bad Gateway` on every `/api/*` (most visibly the Sign-in-with-Google/LinkedIn buttons) on both envs.** **Root cause = containerd shim corruption triggered by host disk pressure (root disk `/dev/vda1` at 92 %), NOT the app / memory / OAuth code.** Heavy `docker compose build` runs (the de-dup `app` + marketing rebuilds) starved containerd on the near-full disk and corrupted the long-lived containers' shims → `aiw-{dev,prod}-api` (and `n8n-n8n-1`) crash-looped `exit 137` (prod `RestartCount` hit **643**). Signature in `/var/log/messages`: `shim disconnected` / `runc … exit status 255` / `task must be stopped before deletion: failed precondition` / `cannot delete running task`. **Red herrings that cost ~10 diagnostic rounds (so next time is fast):** `OOMKilled=false` + empty `dmesg`/`journalctl -k` + `systemd-oomd` inactive + no CSF/lfd = NOT OOM; `docker inspect` flags reflect only the LAST exit (a port conflict we caused) → trust a *live-looping* container's flags, not a stopped one's; `docker events` = `die(137)`+`start` with NO `kill`/`oom` event = a runtime/shim-layer kill, not `docker kill` or a cgroup OOM; a fresh `docker compose run --no-deps api` served live traffic (incl. OAuth `302`) perfectly = the app is fine, the long-lived container's shim was corrupt; the "~62 s" cadence was Docker's maxed restart backoff (`execDuration=2` = real ~2 s lifetime). **Resolution:** `systemctl restart docker` (clears wedged shims; only docker containers blip ~30–60 s, the ~25 cPanel sites are untouched) → `docker compose up -d --force-recreate api` per stack (fresh shim) → verified `/health` 200 + `/api/auth/google` 302 on both. Then disk reclaim: `docker builder prune -f` + `docker image prune -f` (~8.5 G reclaimable = 5.587 G build cache + ~2.9 G dangling images); **never** `docker volume prune` (dev+prod Postgres + n8n/qdrant data) or `docker image prune -a`. **Prevention (host oversubscribed — 5.5 GB RAM / 80 GB @ 92 %):** before any image-building deploy, reclaim disk first (keep < ~85 %) + build **one service at a time** (never parallel), watching `df -h /`. Full write-up in CLAUDE.md "Completed Milestones" + memory `ops-vps-build-shim-crash`. **Note:** the SPA↔marketing de-dup (`563c3f3`) + marketing "Chat is dead" (`a131aa7`) below are STILL undeployed — those are the very builds that tripped this, so do the disk-prune + one-at-a-time preflight before resuming.
>
> **Status (2026-06-12):** **SPA ↔ Marketing de-duplication + app-origin hardening (uncommitted at write time).** The SPA (`app.<domain>`) was shadowing every public marketing page that already lives on the apex Next.js site (`/`, `/about`, `/how-it-works`, `/pricing`, `/investors`, `/contact`) → duplicate content on two domains + `app.aiandhumn.com` fully indexable, splitting SEO authority. Audited via a 7-reader parallel workflow + adversarial verification of every load-bearing claim, then 4 user decisions: **hard-remove** the 5 duplicate routes; SPA `/` → **`RootGate`** (authed → `/dashboard`, unauthed → `window.location.replace(VITE_MARKETING_BASE_URL)`); **noindex** all of `app.aiandhumn.com` **except** `/login`+`/signup` (nginx exact-match exceptions); **harden** the marketing-pricing→checkout handoff (capture `pending-checkout` on `/login` too + forward already-authed visitors straight to `/billing/checkout`). New **`AuthLayout`** (apex-linked chrome) replaces `PublicLayout` on the 4 auth pages → decouples auth from the marketing `landing/` tree → **JS bundle 673→453 KB, CSS 100→63 KB**. Files: SPA `AppRouter`, `RootGate`(new), `AuthLayout`(new), `LoginPage`/`SignupPage`/`Forgot`/`Reset`, `.env.{development,staging,production}` (+`VITE_MARKETING_BASE_URL`), `deploy/cpanel-nginx/aiandhumn/app.aiandhumn.com/proxy.conf`. typecheck+build clean; live-verified (`/login`+`/signup` render with AuthLayout & no marketing nav; RootGate redirects unauthed to apex; `/about` no longer renders). **Deferred:** delete the orphaned (unrouted, tree-shaken) marketing component files after prod-verify; decide whether to drop `MarketingWidget` from the app origin; `/login`+`/signup` share one static index.html meta. **Deploy = rebuild `app` container + install the `app.aiandhumn.com` nginx include + reload + the still-pending marketing `a131aa7`.** Convention in CLAUDE.md ("SPA vs Marketing — strict origin split").
>
> **Status (2026-06-10, DEPLOYED):** **Widget anti-abuse Phase 1 + Phase 4 now LIVE on DEV + PROD — both on `4403b7e`.** Single controlled session; source fast-forwarded `4099423 → 4403b7e`; built `migrate api app widget` + `up -d` per env (migrate by name, per the migration-bearing-deploy rule). **Migration `0025_spike_alerts` applied cleanly on both** — each logged `journal has 26 entries; db has 25 applied; 1 pending` → `[apply]` → `Migrations complete: applied 1.`; `db:status` = **26/26, 0 pending, 0 drift**, zero `[orphan-fix]`. **Phase 4 (spike detection + alerts) fully active** — cron registered hourly (`lock_keys.spike_detection: 2085550514` = `0x7c4ef9b2`) on both. **Phase 1 (front-door gates) deployed in SHADOW mode** — `AIW_REQUIRE_CHALLENGE` / `AIW_REQUIRE_ENGAGEMENT` left unset (default false); the widget sends `X-AIW-Challenge` + the engagement signal (both markers confirmed present in the served `aiw-voice-widget.js` on both envs) but the backend only logs, never rejects. **Per-env env change:** `WIDGET_CHALLENGE_SECRET` set to a real `openssl rand -hex 32`, distinct per env (**gotcha:** the literal placeholder `<DEV_SECRET_FROM_PREFLIGHT>` was pasted into dev `.env` first pass → replaced via `sed` + `up -d api`; harmless in shadow mode but a real hole once enforcement flips). Phase 4 spike vars left at defaults (`OPS_SPIKE_ALERT_EMAIL=updates@aiandhumn.com`, `AIW_SPIKE_MULTIPLIER=2`, `AIW_SPIKE_FLOOR=10`); `AIW_CRON_DISABLED=false` + prod `PADDLE_API_BASE=https://api.paddle.com` confirmed untouched. **Smoke green both envs:** `/health` 200; widget bundle carries both Phase 1 markers; `GET /api/admin/spike-alerts` → `{"ok":true,"alerts":[],…}`; admin "Spike Alerts" nav (`/admin/#spike-alerts`) + customer Activity Health card render. **Route-path gotcha:** the admin spike-alerts JSON API is `GET /api/admin/spike-alerts` (prefix `/api/admin` + handler `/spike-alerts`) — `/admin/#spike-alerts` is the static panel's client-side hash route, a separate surface. **Deferred (not autonomous):** (1) Phase 1 enforcement flip after ~1wk shadow bake — confirm logs clean, flip `AIW_REQUIRE_CHALLENGE=true` (+ `up -d api`), bake, then `AIW_REQUIRE_ENGAGEMENT=true`; (2) optional prod `AIW_SPIKE_FLOOR` bump (e.g. 25) to suppress early false-positive customer emails while 14-day baselines build.
>
> **Status (2026-06-10):** **Widget anti-abuse Phase 1 + Phase 4 shipped to `main` (`4099423` then `a1d20cc`), deployed to DEV + PROD on `4403b7e` 2026-06-10 — see top banner.** Protects customer Retell minutes from bot/script-orchestrated calls that drain a plan's quota. **Phase 1 — front-door gates** (`4099423`): `event.isTrusted` gate on every call-init path (`launcher.ts` rejects programmatic clicks; `widget.ts` `inTrustedClickHandler` flag; `index.ts` gates `window.AIWWidget.open()`); resolve→sessions HMAC challenge (`lib/widget-challenge.ts`, 90s TTL, `X-AIW-Challenge` header, no IP binding); engagement gate (`POST /api/widget/engagement` flips `engagementSeen` true after a real DOM event). Both behind shadow-mode env flags `AIW_REQUIRE_CHALLENGE` + `AIW_REQUIRE_ENGAGEMENT` (default false). **Deploy prereq: set `WIDGET_CHALLENGE_SECRET` (`openssl rand -hex 32`) per env.** **Phase 4 — spike detection + alerts** (`a1d20cc`): hourly cron (`lib/cron/spike-detection.ts`, `CronLockKey.SPIKE_DETECTION = 0x7c4e_f9b2`) flags widgets whose last-hour call count exceeds `max(2× 14-day-median, 10)`; migration `0025_spike_alerts.sql` (spike_alerts table + `widgets.auto_pause_*` + `conversations.request_ip` + `widget_spike_detected` email template). Ops email → `OPS_SPIKE_ALERT_EMAIL` (default `updates@aiandhumn.com`) via a new `sendOpsAlert` recipient override; customer email via F11 `notifyWidgetSpikeDetected`; new admin "Spike Alerts" page (5s poll + acknowledge + unread badge); customer SPA banner + Activity Health card with self-pause/resume. **Self-pause security model**: customer pause sets `service_status_override='paused'` + `auto_pause_set_at`; admin pause leaves `auto_pause_set_at` NULL; customer resume AND re-pause both 409 `ADMIN_PAUSED` on an admin-paused widget; admin endpoint clears `auto_pause_*` on set+clear. Built via parallel-agent recon (9 readers) + 6-dimension adversarial review (38 agents → 14 confirmed → 4 real fixes). **Migration 0025 applied cleanly on DEV + PROD 2026-06-10 (`db:status` 26/26, 0 drift — see top banner).** Pending phases: 2 (per-IP concurrent cap, Postgres-count), honeypot (5.5), Turnstile (1.4). See "Session: Widget anti-abuse Phase 1 + 4" below + `.claude/plans/snappy-leaping-willow.md`.
>
> **Status (2026-06-09):** **Marketing site SEO/GEO/LLMO audit Sections 1-4 deployed to DEV + PROD after a hotfix recovery.** Five SEO commits (`1be2e85` → `a8ea5c8` → `1cbda0f` → `657abdb` → `f967d31`) ran with the strict per-finding triage protocol (one commit per section, summary BEFORE implementation). First deploy uncovered an RSC boundary bug in Section 2's S-04 wiring (`HOMEPAGE_FAQ_ITEMS` exported from a `'use client'` module + imported by a Server Component → Next.js wraps it as a client-reference Proxy → `.map()` throws at render with Digest `330002672`) — DEV + PROD homepage both 500'd simultaneously. Hotfix `3c7ff0a` moved the array to `lib/faqs.ts` (server-safe alongside `buildFaqLd`); marketing rebuilt + verified `200` on both envs. RSC-boundary lesson codified as a Key Convention in CLAUDE.md (don't export shared data from a `'use client'` module). **Phase 3 (dev nginx noindex install) completed 2026-06-09** — `aiworkfllow.com` + subdomains now emit `X-Robots-Tag: noindex, nofollow` (apex + app + api + widget) and serve `Disallow: /` at the apex `robots.txt`. PROD `aiandhumn.com` deliberately remains indexable. **Locked decisions worth carrying forward into any future session:** `SITE_NAME = 'AI&Humn'` (no-space, final), `SITE_LEGAL_NAME = 'AI and Humn Technologies Pvt Ltd'` (ampersand spelled out per JSON-LD legalName), HQ Mumbai (no phone surface), founded 2026, all page metadata via `buildPageMetadata` helper, claim discipline (HIPAA-ready / 99.97% uptime / 240+ sites / EN·HI / 1.8s ALL removed across every marketing surface; homepage FAQ Q5 is the one exception, keeping `"Less than 0.6 sec"` as a specific timing number for AEO retrieval). See "Session: SEO/GEO/LLMO Audit — Sections 1-4" below for the full prose including the hotfix recovery.
> **Status (2026-06-04, PROD pushed):** **PROD now in feature parity with DEV — both on `aa75be2`.** Full Pricing Flow F1-F11 + all 10 post-deploy polish commits live on `aiandhumn.com`. Payload from PROD's previous baseline `c6520a4`: **25 commits + 15 migrations (`0010_password_reset` + 14 pricing migrations `0011 → 0024`)**, applied via the self-healing migrator. **PROD-specific env overrides confirmed**: `PADDLE_API_BASE=https://api.paddle.com` (live host, NOT sandbox — env.ts allowlist would crash boot otherwise); `AIW_CRON_DISABLED=false` (cron MUST run for real customers — F4 hourly replenishment / F5 daily top-up expiry / F11 2-min email dispatch). `RAZORPAY_*` + `PADDLE_*` keys still blank pending KYC — gateways correctly fail-closed with 503 `GATEWAY_NOT_CONFIGURED`. From here, every fork commit needs a 2-target rollout — DEV and PROD have moved into lock-step.
>
> **Status (2026-06-04, latest):** **`aa75be2` fixes a homepage-Pricing-blank race condition.** On a hard load of `/`, the Pricing section was rendering empty; nav-away-and-back made it appear. Root cause: `HomePage.tsx`'s global `IntersectionObserver` (runs once via `useEffect` + double-rAF) was attaching to PricingGrid's loading skeleton — which carries the same `.reveal-stagger` class. When `/api/plans` resolves, React unmounts the skeleton and mounts the real grid as a brand-new DOM node the observer never re-attached to → `opacity: 0` stays forever. Return-nav worked because React Query's cache skips the skeleton and the observer hit the real grid first try. **Fix:** new `RevealGrid` wrapper self-promotes to `is-visible` via `useEffect` + ref on mount, independent of any parent observer's attach timing. Applies to `/pricing` too. **Pattern lesson** (worth remembering): global IntersectionObservers that walk the DOM at mount time will miss any element that an async data loader mounts AFTER that walk completes. Either re-query+attach when data lands, or have the receiving component self-promote. HEAD now `aa75be2`. No backend changes, no migrations.
>
> **Status (2026-06-04, later):** **`0929f2a` added a per-tenant Usage observability surface to the admin panel.** New "Usage by cycle" card on the Tenants page detail (last 5 subscription cycles with minutes used / called count + topup contribution + status; green/amber/red thresholds match the F4 customer dashboard) + new sub-route `#tenants/:tenantId/usage` with unified chronological feed of subscription cycles + top-ups (filter by from/to/type/pageSize; filter state persists in URL). Two new admin endpoints (`/admin/tenants/:id/usage-by-cycle` + `/usage-history`). **Important contract:** the new endpoints aggregate directly from `conversations` (NOT `usage_period_cache`) because the cache is anchored on the 30-day anniversary which diverges from subscription cycles after any plan change — per the `CYCLE-SOURCE-OF-TRUTH LOCK` from F2. Hash router extended to 3-part path `route/param/subroute` (existing routes unchanged). No migrations, no env changes, no new deps. HEAD now `0929f2a`.
>
> **Status (2026-06-04):** **8 follow-up polish commits landed on `main` after the 2026-06-01 DEV deploy of `5ca3ddc`** — all targeting real-world admin-panel + widget gaps that the per-feature swarm reviews missed (the admin panel is static HTML so `tsc` doesn't see it; "load the panel in a browser" wasn't on the swarm-review checklist). HEAD now `d90fa39`. Net effect on the user-facing surface:
>
> - **Admin panel sidebar nav restructure** (`84e6192`) — replaces topbar-button-modals with persistent left-nav (Widgets / Tenants / Plans / Top-up SKUs / Coupons / Tax & invoices / Email automation). New `#widgets/<id>` / `#tenants/<id>` hash router survives reload + back-button. **New Tenants page** with search + table → click-row detail card (owner / subscription / plan snapshot / cycle / widgets / top-ups / invoices).
> - **Tenant-level mutations moved off widget inspector onto Tenants page** (`d90fa39`) — plans + top-ups are tenant-scoped resources; widget inspector now READ-ONLY for tenant concerns with deep-link to Tenants page via hash router.
> - **Six admin JS bugs fixed** that silently broke functionality after the F11 deploy: `API_BASE is not defined` killed the Email tab (`49cd2f1`); `json.data.X` access against a flat `ok()` response wiped 6 list views (`1439050`); raw `= ANY()` SQL threw `uuid = text` on every Coupons GET (`0d6d470`); HTML comment with backticks inside a template literal threw SyntaxError + abandoned the entire `<script>` block, killing every event listener (`e2e8ef9`); **critical data-corruption bug** where Plans editor read flat fields but GET returns nested DTO → form rendered `0`s for untouched fields → save zeroed all unspecified prices + limits (`23a6df8`).
> - **Widget F6 closure** (`0b71679`) — F6-W4 (visitor copy at wrong layer; backend `err.message` was reflected to visitors despite CLAUDE.md convention — now gated by per-code `QUOTA_VISITOR_COPY` table) + F6-W6 (sessionStorage-persisted quota cooldown so page reload doesn't bypass).
> - **Pricing Flow F1-F11 deploy capture** (`6b0e036`) — earlier docs+chore commit recording the 2026-06-01 DEV deploy; also fixes `npm run db:status` to invoke compiled JS (was calling `ts-node` which is stripped from the runtime image by `npm ci --omit=dev`).
>
> **Process lesson encoded:** going forward every admin-panel-touching PR must include `node --check admin/index.html` + a live panel smoke test before merge — `tsc` doesn't see static HTML so the swarm-review pipeline doesn't catch parser-level JS errors.
>
> **PROD still on `c6520a4`** — same payload PROD deploy now includes both the F1-F11 push AND these 8 polish patches.
>
> **Status (2026-06-01):** **DEV deployed on `5ca3ddc` — full Pricing Flow F1-F11 LIVE.** Controlled chunk-by-chunk deploy from `d1256c5` → `5ca3ddc` (16 commits, 14 migrations `0011 → 0024`). Migrations applied cleanly (zero `[orphan-fix]` lines, `Migrations complete: applied 14.`), all 11 features verified live via per-feature smoke matrix (F1 `/api/plans` returns geo-localized INR+USD pricing; F2/F5/F7/F8/F9/F10 routes all 401 = mounted + auth-gated; F4 + F11 crons ticking; F9 webhooks 503 = correct fail-closed with blank gateway creds). **PROD still on `c6520a4`** — same payload PROD deploy is the next chunk. New runtime env vars in `/opt/aiw/dev/.env`: `AIW_SRC`, `AIW_CRON_DISABLED=false`, blank `RAZORPAY_*`/`PADDLE_*` (KYC pending), `PADDLE_API_BASE=https://sandbox-api.paddle.com` (DEV; PROD must be `api.paddle.com`). Route convention learned: F2/F5/F7/F9/F10 SPA-facing routes are under `/api/account/*`, F8 transactional billing actions under `/api/billing/*` — both registered without a `{ prefix: '/api' }` arg. One non-blocking bug surfaced: `npm run db:status` calls `ts-node` which isn't in the runtime image — workaround `node dist/scripts/db-status.js`; one-line `package.json` fix queued.
>
> **Status (2026-05-30, late):** **Pricing module Feature 9 — payment gateway adapters (Razorpay + Paddle) implemented + swarm-reviewed + 22-fix arbiter pass applied; uncommitted on local `main`.** Built in the 6-chunk pattern (migrations `0013_payment_methods` + `0014_billing_idempotency` → gateway interface + Razorpay + Paddle adapters using native fetch/crypto, no SDK deps → POST /api/webhooks/{razorpay,paddle} + processInternalEvent orchestrator → JWT-gated /api/account/payment-methods CRUD → SPA /billing/payment-methods page with feature-flagged Add CTA → admin Payment-methods card). 3-agent swarm review (Architecture + Security + Product/UX) surfaced 3 CRITICAL findings (renewal idempotency Date-equality race, simultaneous webhook delivery race, upsertFromWebhook silently demoting defaults on retry) + 11 HIGH + 6 MEDIUM + 6 LOW. Arbiter consolidated to 22 must-fix items — all applied. Both packages `npm run typecheck` clean. **Migrations 0013 + 0014 NOT yet applied to dev/prod** — run on next `docker compose up -d` in `/opt/aiw/{dev,prod}/`. **Add-card CTA is gated behind `VITE_BILLING_ADD_CARD_ENABLED=false`** until F8 wires the live checkout flow and adapters' live-key roundtrip is verified — customers see "Coming soon" copy with a mailto fallback instead of a 501 on every click. See "Session: Feature 9" below for the full prose.
>
> **Status (2026-05-30):** **Pricing module Features 1 + 2 shipped to `main`** as `bda2e20` (Feature 1 — dynamic geo-localized pricing display) and `f56f398` (Feature 2 — subscription model + period-snapshot grandfathering). Both built in the 6-chunk pattern (schema/migration → backend service/route → SPA helpers → public surface swap → dashboard surface → admin surface) + 3-agent swarm code review (Architecture + Security + Product/UX) → debate → lead arbiter → apply fixes. 23 prioritized fixes applied for Feature 1, 12 for Feature 2. Both pass `npm run typecheck` clean in backend + SPA. Working tree clean. Local `main` is 2 commits ahead of `origin/main` — not yet pushed per convention. **Migrations `0011` + `0012` NOT yet applied to dev/prod** — they run automatically on the next `docker compose up -d` in `/opt/aiw/{dev,prod}/`. See "Session: Pricing module Features 1 + 2" below for the full prose and the locked plan at `.claude/plans/snappy-leaping-willow.md` for the full 11-feature roadmap (decisions, mermaid flows, schema sketches).
>
> **Status (2026-05-25):** **Error-message sanitization shipped to `main` as `6a2fe9d`.** Stopped raw Drizzle SQL strings (`Failed query: select "user_id", "tenant_id"... params: dd@aiworkfllow.io,1`) from leaking into the SPA's red `ErrorBanner` when the DB is unreachable. Layered fix: backend `auth.routes.ts` now uses a `sendAuthError` helper that logs unknown errors server-side and returns a generic 500 (other route modules already did this — auth was the outlier); `app.setErrorHandler` added as global safety net; SPA `api/client.ts` runs every error message through `sanitizeMessage()` that strips SQL/stack/Drizzle/`pg_*`/`ECONNREFUSED` markers and parks the raw text on `err.rawServerMessage` for DevTools-only inspection; `ErrorBanner` re-applies the check at render time. **Not deployed yet** — `cd /opt/aiw/{dev,prod}/ && git pull && docker compose build api app && docker compose up -d api app` (no migrations in this commit; `migrate` doesn't need a rebuild). Convention now codified in CLAUDE.md ("Never reflect raw server error messages to the UI").
>
> **Status (2026-05-23):** **DEV + PROD both deployed on `c6520a4`.** Controlled chunk-by-chunk deploy of 18 commits since `7ee8111`. Headline payload: self-healing drizzle migrator + monotonic-timestamp validation, usage tracking + leads + Retell reconciliation (migrations `0007`/`0008`/`0009`), master ops impersonation, About page + How-it-works rebuild + Wizard Step 4 redesign, AI&Humn wordmark + Urbanist/DM Sans typography, sourcemap leak fix, widget M2 polish + visitor identity, auth-gated marketing widget, conversations + dashboard stats API.
>
> **DB state**: both envs at 10/10 migrations applied. `0007`–`0009` applied cleanly in order on both sides, **no `[orphan-fix]` entries** → DBs were already in monotonic state, no recovery needed. The new self-healing migrator is now the live migration path going forward.
>
> **Smoke tests all green on both envs:**
> - DEV → `api.aiworkfllow.com/health` 200 (8ms), `app.aiworkfllow.com` 200, `widget.aiworkfllow.com/aiw-voice-widget.js` 200
> - PROD → `api.aiandhumn.com/health` 200 (10ms), `aiandhumn.com` 200, `widget.aiandhumn.com/aiw-voice-widget.js` 200
> - API logs clean both sides
>
> **Repo hygiene**: pruned 6 stale worktrees + 8 dead `claude/*` branches (all 0 commits ahead of main, all merged work). Trunk-only setup now — `main` is the single source of truth. Going-forward protocol: all commits + pushes route through the main worktree, no more spinning up `claude/*` branches without explicit ask.
>
> **Previous status (2026-05-17)**: PROD went live at `aiandhumn.com` (apex SPA), `api.aiandhumn.com`, `widget.aiandhumn.com`. Apex SPA pattern differs from dev's subdomain — nginx user-include at `/etc/nginx/conf.d/users/aiandhumn/aiandhumn.com/proxy.conf` has an `if ($host = "www...") return 301` block above the SPA proxy. Separate Google + LinkedIn OAuth apps (callback: `https://api.aiandhumn.com/api/auth/{google,linkedin}/callback`). SMTP via cPanel mail account `updates@aiandhumn.com` port 465 SMTPS. DNS hosted on Cloudflare with proxy status OFF / DNS-only.
>
> **Follow-ups still pending** (not deploy-blocking):
> - `www.aiandhumn.com` CNAME in Cloudflare DNS — nginx www→apex 301 redirect is already configured, just waiting for the record
> - Marketing widget `.env.production` cleanup — drop `/dist/` from `VITE_MARKETING_WIDGET_BUNDLE_URL`, swap `VITE_MARKETING_WIDGET_TOKEN` to a PROD widget token
> - `/privacy-policy` + `/terms` SPA routes at `aiandhumn.com` — needed before publishing Google/LinkedIn OAuth consent screens for PROD
> - Compose build-context cleanup — kill the historical VPS-side sed-patch workaround that points contexts at absolute `/opt/aiw/src/...` paths (worked fine this round; still worth removing)

## 🔔 Reminders to surface AFTER PROD deploy

These are explicitly parked until the prod stack at `aiandhumn.com` is live and smoke-tested. Do **not** ship them as part of the prod deploy commit.

- **Build `/privacy-policy` SPA route** — public page at `https://aiandhumn.com/privacy-policy` with AI&Humn-specific privacy policy content (cover: account email/OAuth provider data, voice transcripts processed by Retell, KB document content, cookies = none yet, data retention, contact email).
- **Build `/terms` SPA route** — public page at `https://aiandhumn.com/terms` with AI&Humn-specific Terms of Service (cover: acceptable use, widget embedding rights, customer's responsibility for end-user disclosure, indemnification basics, governing law).
- Wire both into `PublicLayout` footer + `AppRouter`. Pattern is identical to the existing `/about`, `/contact`, `/pricing` etc. routes added in commit `4a0796d`.
- Link both from the Google OAuth consent screen ("App information → Privacy policy URL / Terms of Service URL") to satisfy verification.

---

> **Last updated:** 2026-05-23
> **Status:** **Three milestones now SHIPPED to DEV + PROD** (rolled up into the 2026-05-23 deploy on `c6520a4`):
>
> 1. **Usage minutes tracking + Retell reconciliation** — `0008_usage_tracking.sql`, `usage_period_cache`, `/api/usage/*`, admin reconciliation flow with audit trail. Full prose below. ✅ migrated on both envs.
> 2. **Leads dashboard** — `0007_session_captures_name.sql` extends `session_captures.kind` to include `'name'`, adds `(tenant_id, captured_at DESC)` index. New `src/modules/leads/` rolls captures up by `provider_call_id` (one Lead per call). New SPA `/leads` route with infinite-scroll table + widget/date filters + conversation deep-links. ✅ migrated on both envs.
> 3. **Master ops impersonation** — `0009_impersonation.sql` adds `users.is_staff` + `impersonation_audit_logs`. New `POST /api/admin/impersonate` with two-layer auth (admin key + staff email/password) and mandatory audit-row-before-token. New SPA `/auth/impersonate` landing + sticky `ImpersonationBanner` shown when `me.impersonatedBy` is set. `npx ts-node src/scripts/seed-staff.ts <email>` flips a real user into ops staff. ✅ migrated on both envs.
>
> All three migrations (`0007`, `0008`, `0009`) applied cleanly on both envs in the 2026-05-23 deploy. Bundled with the self-healing migrator (`c6520a4`) that now validates `_journal.json` monotonicity + recovers any out-of-order entries via hash lookup.
>
> **Previous status (still applies):** Typography refresh + wordmark logo deployed to DEV. App fonts swapped from Libre Baskerville + Inter to **Urbanist (display) + DM Sans (body)** to pair with the new Monr-based "AI & Humn" wordmark; Geist Mono retained for code. The committed SVG wordmark (`e58a51a`, fill `#feca00`) is now wired into the three header surfaces — landing TopNav, dashboard sidebar, admin topbar — at sizes matching the prior text-mark footprints. Two commits on `origin/main` (`eb57683` logo wiring, `cca3cbf` font swap), `docker compose build/up` complete on `/opt/aiw/dev/`. Live at `app.aiworkfllow.com` + `api.aiworkfllow.com/admin/`.
>
> **Previous status (still applies):** M2 widget polish + anti-abuse + visitor identity work is on `main` as `44f15ac`. Five PRs + transcript reconciler follow-up + trigger-safety fix shipped together. Migrations `0004` / `0005` / `0006` ran on DEV. Bundle 527.7 KB.
>
> **Previous status (still applies):** Conversations pipeline live on DEV. RetellAI webhooks attach call transcripts, recordings, and post-call analysis to a `conversations` table; new `/dashboard` stats overview + per-widget `/widgets/:id/conversations` page surface them; marketing widget orb is auth-gated (logged-out visitors only). Retell agent `webhook_url` auto-synced on link-agent. PROD path is the same `deploy/README.md → "First deploy — PROD"` flow whenever it's time.

## Recent commits since last handoff

| Commit | Summary |
|---|---|
| `4403b7e` | docs: record the widget anti-abuse Phase 1 + 4 session in handoff.md + CLAUDE.md (also backfilled previously-unwritten pricing-flow F1-F11 history). No runtime code. Deployed to DEV + PROD 2026-06-10 (this commit = deploy HEAD on both envs). |
| `a1d20cc` | feat(backend,app,admin): widget spike detection + ops/customer alerts (anti-abuse Phase 4) — migration `0025_spike_alerts.sql` (`spike_alerts` table UNIQUE(widget_id, window_start) = one alert/widget/hour; `widgets.auto_pause_reason` + `auto_pause_set_at`; `conversations.request_ip`; extends `email_templates` event_key CHECK + seeds `widget_spike_detected`). Hourly cron `lib/cron/spike-detection.ts` (`CronLockKey.SPIKE_DETECTION = 0x7c4e_f9b2`): candidate widgets with ≥ `AIW_SPIKE_FLOOR` (10) calls last rolling hour; baseline = `PERCENTILE_CONT(0.5)` over 14d hourly counts (active hours only); alert when `observed > max(AIW_SPIKE_MULTIPLIER(2) × median, FLOOR)`; idempotent `ON CONFLICT DO NOTHING RETURNING` → emails once/hour; advisory-lock TX + post-TX dispatch (F4 pattern). Ops email via `sendOpsAlert` new optional `recipientEmail` → `OPS_SPIKE_ALERT_EMAIL` (default `updates@aiandhumn.com`); customer email via F11 `notifyWidgetSpikeDetected`. Admin "Spike Alerts" LHS-nav page (`admin-spike-alerts.routes.ts` + `modules/spike-alerts/spike-alerts.service.ts`; `GET /admin/spike-alerts` + `PATCH /:id/ack`; 5s poll route-guarded + red unread badge). Customer SPA `WidgetActivityHealth.tsx` (amber banner above widget grid w/ self-pause + dismissal persisted per (widget,window) in sessionStorage; Activity Health card w/ pause/resume); JWT/tenant `GET /api/widgets/:id/spike-alerts` + `POST /pause` + `/resume`. **Self-pause security**: customer pause sets `service_status_override='paused'` + `auto_pause_set_at`; admin pause leaves `auto_pause_set_at` NULL; customer resume AND re-pause both 409 `ADMIN_PAUSED` on admin-paused widget; admin service-status endpoint clears `auto_pause_*` on set+clear. Reviewed by 6-dimension adversarial workflow (38 agents → 14 confirmed → 4 real fixes; rejected drizzle-numeric-string + trailing-breakpoint + default('0') false positives). Note: a review agent accidentally ran `db:generate` (NOT this project's migration path) → spurious `0026_*` deleted + journal restored. |
| `4099423` | feat(widget,backend): anti-abuse Phase 1 — `event.isTrusted` gate + HMAC challenge + engagement gate. `launcher.ts` rejects programmatic clicks (`!event.isTrusted`); `widget.ts` sets a synchronous `inTrustedClickHandler` flag (cleared in a microtask); `index.ts` gates `window.AIWWidget.open()` on it. BehaviorTracker auto-open deliberately ungated (no DOM event exists there — `isTrusted` is an Event property only; defended by the engagement gate). Resolve→sessions HMAC challenge (`src/lib/widget-challenge.ts`): `/api/widget/resolve` mints `base64url(payload).HMAC-SHA256(payload, WIDGET_CHALLENGE_SECRET)` w/ 90s TTL in `iat`, no IP binding (mobile network switches); `/api/sessions` requires it as `X-AIW-Challenge`. Engagement gate: challenge payload carries `engagementSeen`; `POST /api/widget/engagement` re-mints it true after mousemove/scroll/keydown/pointerdown/touchstart/click/visibilitychange. Rollout flags `AIW_REQUIRE_CHALLENGE` + `AIW_REQUIRE_ENGAGEMENT` (default false = shadow). Deploy prereq: `WIDGET_CHALLENGE_SECRET` (`openssl rand -hex 32`) per env. |
| `f967d31` | feat(marketing,seo): Section 4 LLM-search audit (L-01..L-06) — ship `aiw-voice-marketing/public/llms.txt` (claim-set-aligned body — "Low Latency for real-time conversations" replaces the audit-suggested 1.8s line). Latency claim refresh across 7 surfaces (1.8s → "low latency" everywhere except homepage FAQ Q5 which becomes `"Less than 0.6 sec"` for AEO retrieval): STATS marquee, Differentiation table, FAQ Q5, investors METRICS card, /how-it-works section lead + Step 04 body + meta description. Dev-env noindex shipped: A) new `deploy/cpanel-docroots/aiworkfllow/robots.txt` (`User-agent: *` / `Disallow: /`), installs at `/home/aiworkfllow/public_html/`; B) `X-Robots-Tag: noindex, nofollow` header with `always` flag on every location in all 4 dev nginx user-includes (apex + app + api + widget). PROD `aiandhumn` user-includes deliberately untouched. Closed L-02 as duplicate of S-04; deferred L-03 (`/blog`), L-04 (Bing/IndexNow code queued behind user's Bing registration), L-05/L-06 (non-code growth tracks). |
| `657abdb` | feat(marketing,seo): Section 3 on-page audit fixes (O-01..O-05) — six SEO-optimized titles via `title: { absolute: '...' }` (/about → "About AI&Humn — Voice Conversion Layer for the Web", /how-it-works → "How to Add a Voice AI Widget to Your Website — AI&Humn", /pricing → "Voice AI Widget Pricing Plans — From $40 (₹1,999)/mo — AI&Humn", /security → "Security & Compliance — GDPR, DPDP, Enterprise-Ready — AI&Humn", /contact → "Contact AI&Humn — Support, Partnerships & Press"; /investors + /privacy-policy + /terms keep templated). DOM-text word-boundary fix on `HiwHero` + `AboutHero` Framer Motion word-stagger — each word now `<Fragment key={i}><motion.span variants={word}>{w}</motion.span>{i<n-1 && ' '}</Fragment>`; old CSS `marginRight: '0.3em'` dropped from `styles/about.css`. `availability: 'https://schema.org/InStock'` on every pricing Offer (USD + INR). Full claim cleanup across 8 surfaces (HIPAA-ready / 99.97% uptime / 240+ sites / EN·HI / RBI-aligned ALL removed): STATS marquee 4 entries verifiable; hero caption EN·HI → EN; healthcare + fintech use-case notes enterprise-framed; DataHandling rewritten; homepage FAQ Q6 drops HIPAA; about principle now "GDPR-aware, India DPDP-aligned, multi-tenant from day one"; investors METRICS card swaps 99.97% + 240+ for Founded 2026 + HQ Mumbai. |
| `1cbda0f` | feat(marketing,seo): Section 2 schema audit fixes (S-01..S-04) — full Organization JSON-LD in `app/layout.tsx` (`@id`, founder Dhruv Dholakia, sameAs LinkedIn + X, PostalAddress Mumbai/IN, foundingDate 2026, legalName `"AI and Humn Technologies Pvt Ltd"`, description = `SITE_LONG_DESCRIPTION`). WebSite JSON-LD with publisher `@id`-linked. SoftwareApplication JSON-LD with AggregateOffer dual-currency (`PRICING_RANGE_INR = { lowPrice: '1999', highPrice: '12999', offerCount: '3' }`, `PRICING_RANGE_USD = { lowPrice: '40', highPrice: '250', offerCount: '3' }`), 8-item featureList (no Hindi per claim discipline). FAQPage JSON-LD on homepage + pricing via new `buildFaqLd(items)` helper in `lib/faqs.ts`; `HOMEPAGE_FAQ_ITEMS` exported at module scope from `components/home/SectionsB.tsx` + `BILLING_FAQ` exported from `app/pricing/page.tsx` so the visible accordion and the schema read from the same source — can never drift. S-02 = Option A (single publisher@id source-of-truth for org references). S-05 BreadcrumbList deferred until URL structure deepens. |
| `a8ea5c8` | feat(marketing,seo): Section 1 SEO audit fixes (T-02..T-06, T-09) — `SITE_NAME = 'AI&Humn'` (no-space, final), `SITE_DESCRIPTION` 152-char meta-description form, new `buildPageMetadata({path, title, description})` + `buildAlternates(path)` helpers in `lib/site.ts`. Title shape `string | { absolute: string }` — simple string gets ` — AI&Humn` suffix from global `title.template` (`'%s — AI&Humn'` in `app/layout.tsx`); `absolute` is rendered verbatim. Per-page OG / Twitter via the helper (was inheriting homepage values across all routes — different OG image / title / description per page). T-09 address: Mumbai / India only, no phone. T-06 sitemap lastmod infrastructure (`scripts/generate-lastmod.mjs` + `lib/lastmod.generated.json` + `app/sitemap.ts`) ships but is non-functional in Docker (no `.git` / no `git` binary in container) — won't-fix, infrastructure left in place for future non-Docker hosting. T-07 (`/blog`) + T-08 (body-copy brand standardization "AI&Humn" vs "AI & Humn") deferred. |
| `1be2e85` | fix(marketing,seo): inject primary keyword into homepage hero lead paragraph (T-01) — hero `<p>` lead now opens with `"AI & Humn is a voice AI widget for websites — embed a voice agent into your site in minutes. Visitors click, speak, and get answers grounded in your business — in real time. No phone tree, no form, no chat queue. Just conversation."`. Primary keyword landing for search-engine + LLM retrieval. Shipped ahead of Section 1's bundled commit because T-01 was a single-paragraph edit with no triage ambiguity. |
| `aa75be2` | fix(landing): **homepage Pricing section stays blank on first paint** — `HomePage.tsx` mounts a single global `IntersectionObserver` in a `useEffect` (after a double-rAF) that walks `document.querySelectorAll('.reveal, .reveal-stagger').forEach(observe)` ONCE. At that moment `PricingGrid` is rendering its loading skeleton (which carries the same `.reveal-stagger` class) so the observer attaches to the SKELETON node. When `/api/plans` resolves, React unmounts the skeleton and mounts the real grid — a brand-new DOM node the observer never re-attached to. `.reveal-stagger > *` default `opacity: 0` stays applied forever → section blank. Return-nav worked because React Query cache delivers data synchronously so the skeleton is skipped. **Fix:** new `RevealGrid` wrapper self-promotes to `is-visible` via `useEffect` + ref on mount, independent of parent observer timing. Staggered-children CSS animation still triggers via the parent class. Applies to `/pricing` too for behavioural consistency across all `PricingGrid` mount sites. |
| `0929f2a` | feat(admin): **Usage by cycle card + detailed usage history on Tenants page** — two new endpoints (`GET /admin/tenants/:id/usage-by-cycle?limit=5` + `GET /admin/tenants/:id/usage-history?from=&to=&type=&page=&pageSize=`); aggregates directly from `conversations` (NOT `usage_period_cache`) per `CYCLE-SOURCE-OF-TRUTH LOCK` because the cache's 30-day anniversary diverges from subscription cycles after any plan change. New "Usage by cycle" card on tenant detail (green<75%/amber/red≥100% thresholds matching F4 customer dashboard). New sub-route `#tenants/:tenantId/usage` with filter bar (from/to/type/pageSize/Apply/Reset/Back) + URL-persisted filter state + unified pagination of subscription cycles + topups. Hash router extended to 3-part path `route/param/subroute` + optional `?qs` tail (existing 2-part routes unchanged). Card auto-reloads after any tenant-level mutation. Edge cases inline: never-subscribed → empty hint; pre-migration-0022 cycles → `topup_minutes_debited` shows `—` (never imply zero); refunded topups → row dimmed + strikethrough usage; filter `from > to` → backend 400 `BAD_RANGE`; page beyond last → clamped server-side. No migrations, no env changes, no new deps. |
| `d90fa39` | refactor(admin): move plan + top-up mutations to Tenants page — widget inspector now READ-ONLY for tenant-scoped resources (Subscription card deep-links to Tenants page via hash router; Top-ups card removed; runtime Quota card retained for widget-level diagnostics). Tenants page detail card hosts Force-replenishment-tick, Cancel-pending-change, Unassign-plan + Assign/Switch-plan disclosure form. |
| `84e6192` | feat(admin): sidebar nav restructure + Tenants page + plan/top-up assignment — replaces topbar-button-modals with persistent left-nav (Widgets / Tenants / Plans / Top-up SKUs / Coupons / Tax & invoices / Email automation). Hash router (`#widgets`, `#widgets/<id>`, `#tenants`, `#tenants/<id>`, `#plans`, `#tax`, `#coupons`, `#topup-skus`, `#email`) survives reload + back-button. New Tenants page with search + table → click-row detail card (owner + subscription + plan snapshot + cycle + widgets + top-ups + invoices). 5 catalog modals migrated to sidebar pages via `migrateModalsToPages()`; lazy `_plans()`/`_tax()`/`_cpn()`/`_ts()` getters resolve post-move identities; wire-once-guards prevent double-attach on re-nav. |
| `1439050` | fix(admin): 6 list endpoints read `json.data.X` but the backend `ok()` helper spreads payload flat (`ok({skus}) → {ok:true, skus}`). Switched all 6 to top-level access (Top-up SKUs / Email templates / Email preview / test-send / Email log + footer count). |
| `49cd2f1` | fix(admin): F11 Email tab — all 5 fetches threw `ReferenceError: API_BASE is not defined` (correct constant is `API = '/api/admin'`). Replaced with `API + '/email-templates...'` (also fixed redundant `/admin/` doubling in the paths). Top-up SKU 500s now log real errors instead of silent generic banner. |
| `0d6d470` | fix(coupons-admin): replace broken raw `sql\`${couponRedemptions.couponId} = ANY(${ids})\`` (PG 14+ has no `uuid = text` implicit cast → "operator does not exist" thrown on first GET because migration 0016 seeds WELCOME20) with drizzle's `inArray()` like every other coupon module. Catch blocks expanded to log real errors. |
| `23a6df8` | fix(plans-admin): **critical data-corruption bug** — Plans editor read flat fields (`p.priceInrMonthlyCents`) but GET `/admin/plans` returns the F1 nested DTO (`p.prices.inr.monthly`). Reading `undefined` → `num()` → `0` → form value `"0"` → admin saves untouched fields as `"0"` → backend accepts (0 is non-negative) → silent zero-write to all unspecified price + limit columns. Whatever the real values were is gone. Patched the editor to read nested + write back nested + render `₹X/mo` correctly. |
| `0b71679` | fix(widget): close F6-W4 + F6-W6 gaps — F6-W4 (visitor copy at wrong layer): widget was passing backend `err.message` verbatim to the visitor transcript despite the "never reflect raw server errors" convention. Added module-scope `QUOTA_VISITOR_COPY` table keyed on `err.code` (SERVICE_PAUSED/PAYMENT_FAILED → full UX P1-2 copy; QUOTA_EXCEEDED → "try again shortly"; NO_SUBSCRIPTION → "isn't currently available"). Backend message now DELIBERATELY IGNORED for visitor transcript. F6-W6 (sessionStorage cooldown missing): quota cooldown was in-memory only; reload bypassed it. Now persisted via `sessionStorage` keyed by widget token, `applyQuotaCooldown()` in `init()` re-applies on next load. |
| `e2e8ef9` | fix(admin): unbreak Set-key button + ALL admin actions — one HTML comment containing `\`plans\`` (backticks!) was inside a JS template literal in `renderInspector()`. Template literals don't honour HTML comment syntax; inner backticks terminated the outer literal early; parser threw SyntaxError + abandoned the ENTIRE `<script>` block. Every event listener silently failed to attach. Page rendered as inert HTML. Replaced backticks with single quotes. `node --check` added to verification workflow. |
| `6b0e036` | docs+chore: capture 2026-06-01 DEV deploy of `5ca3ddc` (F1-F11 pricing flow) — 16 commits rolled up (`d1256c5 → 5ca3ddc`), 14 migrations applied cleanly (`0011 → 0024`, zero `[orphan-fix]` lines), all 11 features confirmed live via per-feature smoke matrix. Bonus fix: `npm run db:status` now invokes compiled JS (was calling `ts-node` which is stripped from runtime image by `npm ci --omit=dev`). |
| `5ca3ddc` | feat(pricing): email automation — lifecycle templates + dispatcher cron + plan-change advance notice (Feature 11) + swarm-review patches — migration `0024_email_automation.sql` creates `email_templates` (admin-editable subject/body per `event_key` + `min_notice_days`) + `email_send_log` (append-only audit, FROZEN rendered content at enqueue, `idempotency_key` partial-UNIQUE for fire-once-per-cycle). 11 lifecycle events: subscription_started, renewal_succeeded, payment_failed, trial_ending, quota_threshold_80/100, topup_expiring, plan_upgraded, downgrade_scheduled, invoice_issued, plan_changed (Decision #22 advance notice). New `email.service.ts` (renderTemplate with variable allowlist + HTML escape + CRLF strip, enqueue with catch-23505 idempotency, dispatchQueued with claim-then-send-then-update), `lifecycle.ts` (9 per-event wrappers, fire-and-forget, deterministic idempotency-keys), `plan-change-notifier` (per-subscriber price-increase detection, schedules email at `renewal_date - min_notice_days`). Cron `CronLockKey.EMAIL_DISPATCH (0x6b3d_e8a1)` 2-min cadence, F4 lock pattern. Wired into F9 handleSubscriptionCharged/PaymentFailed, F10 applyUpgrade/scheduleDowngrade, F5 topup-expiry cron (60d/14d/7d), F6 checkAndNotifyQuotaThreshold (80/100), F1 admin `PATCH /plans/:planId?notifySubscribers=true`. Headline arbiter fixes: dispatcher tx-threading (claim inside lock-holding tx, send outside), test-send recipient allowlist. |
| `75be9a6` | feat(pricing): plan upgrade/downgrade lifecycle (Feature 10) + swarm-review patches — migration `0023_subscription_change_events.sql` (audit ledger). Customer change-plan / cancel / resume / cancel-pending-change surface across backend service / routes / orchestrator / SPA / admin panel. Decisions: #15 mid-cycle UPGRADE (immediate, proration, old-cycle minutes forfeited, anchor RESETS); #16 mid-cycle DOWNGRADE (applies at next renewal; `pending_change` jsonb carries frozen targetSnapshot so admin edits don't move goalposts); #17 after-expiry resubscribe (fresh signup, top-ups carry over via 12-month lifetime); #20 cancel (cancel_at_period_end, no prorated refund v1). **GATEWAY-CHARGE LOCK**: razorpay/paddle subscribers see `501 GATEWAY_UPGRADE_NOT_IMPLEMENTED` for immediate upgrades (F9 has no `chargeStoredPaymentMethod` yet); manual gateway gets end-to-end immediate-upgrade. Scheduled downgrades + cancel + resume work for ALL gateways. Snapshot override at renewal so frozen terms survive admin edits. Gateway cancel called BEFORE local TX; 502 on adapter error; F9 webhook honors `cancelAtPeriodEnd` defensively. SPA success banner branches on `?planChange=upgraded\|downgrade_scheduled`. Forfeited-minutes warning above proration table. F4 cron excludes `cancelAtPeriodEnd=true` subs from past-due alerts. |
| `db17452` | feat(pricing): per-plan entitlements — transmission-gate filter + write-path 403s + SPA conditional render (Feature 3) + swarm-review patches — every per-plan feature (behavioural triggers, signed visitor identity, lead-capture pill, remove-branding, multilingual, transcript-search, post-call-analysis, unlimited-widgets, priority-support) enforced server-side by FILTERING the `/api/widget/resolve` response — embed never receives config for a feature the tenant isn't entitled to. Backend write-paths (trigger CRUD, visitor-identity toggle, widget create) return `403 ENTITLEMENT_REQUIRED` before any DB mutation. Reads from active subscription's FROZEN `plan_snapshot.entitlements` (F2 snapshot per Decision #7) with live-plan fallback for legacy non-subscribed tenants. **F3 ENTITLEMENT-TRANSMISSION-GATE LOCK** added: entitlements ship over HTTP in exactly TWO places: `/api/widget/resolve` (entitlements stripped, only entitled feature config survives) and `/api/account/subscription` (owner-only). F1 SECURITY LOCK strip on `/api/plans` preserved. Headline fix: stranded visitor-identity post-downgrade — widget with `visitorIdentityEnabled=true` renders `<PausedByPlan>` banner above VisitorIdentityCard so customer keeps disable-toggle access; backend permits DISABLE without entitlement (only ENABLE gated). No new migration. |
| `90bd310` | feat(pricing): quota gating — two-gate model + Retell max_duration cap + topup-debit reconciliation (Feature 6) + swarm-review patches — Gate 1 `/api/widget/resolve` returns `service_status` for page-load UX (cacheable advisory); Gate 2 `/api/sessions` does authoritative per-call check + sets Retell `max_duration_sec` (≤ 30 min hard cap per Decision #14). Reads entitlement window from `subscription_usage_cycles` for subscribed tenants (CYCLE-SOURCE-OF-TRUTH LOCK from F2). Calls F5's `debitFifo` after every call_ended. Admin can force-override the gate (escape hatch for billing-dispute pauses + accidentally-failed-webhook recoveries) — override always wins, audit-logged to `provider_webhook_events`. **F6 SECURITY LOCK**: `service_status` decision computed SERVER-SIDE from server-truth state ONLY; no client-supplied input can influence status. **RECONCILE-PER-CYCLE LOCK**: `already_debited` scoped to current cycle via `subscription_usage_cycles.topup_minutes_debited` (Arch P0 caught cross-cycle math bug that under-counted overflow whenever prior cycle ate topups). Migrations: `0021_widget_service_status.sql` (status enum + admin override + audit-trail), `0022_topup_debited_per_cycle.sql` (per-cycle reconciliation column). |
| `4be9a1e` | feat(pricing): top-ups — SKU catalogue + per-tenant packs + FIFO + expiry cron (Feature 5) + swarm-review patches — minute packs stacking on monthly quota; 12-month per-pack expiry (Decision #13); FIFO consumption (Decision #9 — F6 hook ready); gateway-mediated purchase through F8 checkout via `purchaseType` discriminator. Migration `0020_topups.sql`: `topup_skus` + `topups` + deferred FKs from `coupon_redemptions.topup_id` + `invoices.topup_id` via NOT VALID + VALIDATE; seeds 3 starter SKUs (`basic`/`extra`/`extra_plus`). Backend `topup.service` (listSkus, getTopupBalance, createTopupForPurchase via gateway-txn UNIQUE idempotency, adminGrantTopup/Refund with status-guard, debitFifo F6 hook with FOR UPDATE SKIP LOCKED, markExpiredFifo cron hook, computeExpiresAt calendar-month math). `checkout.service.ts` extended to discriminated union (plan\|topup); Razorpay via Orders /v1/payment_links; Paddle via ad-hoc Transactions price; webhooks emit `topup.purchased`. F5 SECURITY LOCK re-validation in `tryRedeemTopupCouponOnce`. Daily expiry cron `CronLockKey.TOPUP_EXPIRY (0x5a2c_d4f7)` + post-TX alerts. SPA: TopupCard on /billing (currency waterfall sub→tax→geo + real `<button disabled>` when not subscribed or tax-details missing); TopupsPage at /billing/topups (mobile-responsive collapse + aria-live balance); CheckoutPage branches on purchaseType with forfeiture-explicit copy. |
| `3f88fa6` | feat(pricing): replenishment cron + cycle audit + cycle-stuck UX (Feature 4) + swarm-review patches — hourly cron closes elapsed monthly cycles + rolls yearly mid-period cycles (Decision #8 — minutes refresh monthly regardless of billing cadence; yearly subs get 12 monthly usage_cycles per paid billing_period). Plus admin force-tick + customer "Minutes refresh on" surface with new yellow `cycleStuck` banner. Multi-instance safety via `pg_try_advisory_xact_lock` threaded through `db.transaction(tx)` — every SELECT/UPDATE/INSERT runs on connection holding the lock. Post-TX alert dispatch (rollback never ships false-positive Slack/email). `AIW_CRON_DISABLED` env opt-out for dev/CI. Migration `0019_cycle_closed_at.sql` adds nullable `subscription_usage_cycles.closed_at` for triage. **F4 ENTITLEMENT-NARROWING LOCK** closed: cron NEVER NARROWS ENTITLEMENT (prior code flipped `status=active→closed` BEFORE successor cycle existed → F6 quota-gate gap window of seconds-to-26h while gateway webhook landed). Now: monthly cycles stay active until F9 webhook closes-then-inserts in same TX; cron only observes (defensive close if successor exists = orphan from F9 missing close step; 26h past-due alert if no successor; else no-op). Yearly mid-period rollovers DO close+insert in same TX because cron IS the successor source. **F9 CLOSE-OLD-CYCLE CONTRACT** added. |
| `eac88a7` | feat(pricing): checkout + coupons — unified checkout flow (Feature 8) + swarm-review patches — single `/billing/checkout` page wires F1+F2+F7+F9. Server resolves currency from `tenant.customer_tax_details.country` (F1 SECURITY LOCK), validates+applies coupons from our own DB (**gateway-agnostic — one code works across Razorpay-INR + Paddle-USD**), computes tax via F7, hands off to F9 adapter, then F9 webhook orchestrator inserts coupon redemption audit row after subscription+invoice exist. 3 migrations: `0016_coupons` (coupons + coupon_redemptions; types percentage/fixed_amount/free_trial; seeds WELCOME20), `0017_plan_gateway_ids` (jsonb mapping `{razorpay:{monthly,yearly},paddle:{monthly,yearly}}`), `0018_coupon_constraints` (partial UNIQUE `(coupon_id,subscription_id) WHERE topup_id IS NULL` + CHECK fixed_amount currency alignment). JWT-gated `/api/billing/checkout/{preview,complete}` + `/api/billing/coupons/validate` (currency NEVER in body). SPA CheckoutPage with debounced coupon preview, gateway-URL host allowlist, deep-link plumbing /signup → /verify-email → /login → checkout via localStorage 30-min TTL. Admin Coupons CRUD with soft-delete. **F8 SECURITY LOCK**: re-validate coupon discount + tax against LIVE prices in webhook (never trust event.amountCents). Headline fixes: **PADDLE DISCOUNT MISROUTE closed** (was sending internal code into `discount_id` which expects `dsc_xxx` UUID), **GATEWAY-CHARGE-FULL-PRICE BUG closed** (adapters were sending catalogue gatewayPlanId regardless of coupon → customer charged full price while redemption logged phantom discount). |
| `0fee171` | feat(pricing): tax module — Indian GST + Paddle MoR (Feature 7) + swarm-review patches — India GST (CGST 9% + SGST 9% intra-state, IGST 18% inter-state) generated in-house with GST-compliant PDFs; international invoices delegated to Paddle as Merchant of Record (Decision #12). 4 new tables via migration `0015_tax_invoices.sql`: tax_profiles (our GSTIN/state/FY-format), customer_tax_details (per-tenant country/state/GSTIN), invoices (append-only ledger with `branch` enum `'inr_domestic'\|'paddle_mor'`), invoice_sequences (FY-keyed atomic counter, SELECT FOR UPDATE). NOT VALID + VALIDATE deferred FK to F2. JWT-gated customer routes (tax-details GET/PUT, invoices list, PDF download); admin routes (tax-profile CRUD, GSTR-1 CSV export, cross-tenant PDF access). Wired into F9 webhook orchestrator via `issueInvoiceForPeriod` hook; new generic `sendOpsAlert()` helper routes TRIAGE alerts (unknown_plan_code, invoice_create_failed, tax-drift) to Slack/email. P0 fixes: **PATH-TRAVERSAL CVE closed** (`safeInvoiceFilename` + realpath containment on `streamPdf`; admin Zod allowlist with required `{fy}+{seq}` tokens), **CSV FORMULA INJECTION (OWASP) closed** (`csvEsc` prepends apostrophe to cells starting with `= + - @` tab CR), **F7 SECURITY LOCK** on PUT /api/account/tax-details (once subscription active, customer-tax country pinned to subscription currency INR↔IN / USD↔non-IN — mirrors F1 LOCK shape), **F7 ORCHESTRATOR-TAX CONTRACT** (gateway-reported tax authoritative; INR drift > ₹1 raises TRIAGE), USD invoice total now correctly equals gateway-charged amount, TaxDetailsCard unconditionally mounted on /billing (closes India B2B input-tax-credit gap). |
| `c4ca09c` | docs(deploy): add pricing-flow deploy queue tracker — `deploy/pricing-flow-deploy-queue.md` ledger tracking what's shipped to DEV vs PROD per-feature. |
| `980be47` | chore(deploy): harden multi-migration deploy path for upcoming pricing-feature fan-in — `AIW_SRC` env required by compose for `${AIW_SRC}` build-context interpolation; replaces the historical sed-patch workaround. |
| `f56f398` | feat(pricing): subscription model + period-snapshot grandfathering (Feature 2) + swarm-review patches — migration `0012_subscriptions.sql` adds three tables (`subscriptions` / `subscription_billing_periods` / `subscription_usage_cycles`) with partial unique index on `subscriptions(tenant_id) WHERE status IN ('active','trialing','past_due','paused')` (canceled subs don't block re-subscription) and circular FK resolved via `DO $ ALTER TABLE ... ADD CONSTRAINT $` after both tables exist. New `src/modules/subscription/{service,routes,schemas}.ts` — read endpoints `GET /api/account/subscription{,/history}` (JWT-gated) + internal `takePlanSnapshot` / `createSubscription` (with 23505 idempotency catch on concurrent re-subscribe) / `createNextBillingPeriod` (all `@internal` JSDoc-tagged). Split DTOs: `PlanSnapshot` (internal w/ `entitlements`) vs `PlanSnapshotPublic` (no entitlements) via `toPublicSnapshot` boundary helper — entitlements stripped before any HTTP response leaves the server (Sec H1 fix). `getCurrentSubscription` has a cycle-window fallback for transient renewal-cron lag. New admin route `GET /admin/tenants/:tenantId/subscription` (UUID-validated). Seed creates subscription + first billing period + first usage cycle for dev tenant. New SPA `/billing` route (`BillingOverviewPage.tsx`) with past_due/paused recovery banners, trial countdown, `pendingChange` disclosure banner (Feature 10 will write this), yearly format parity with PricingGrid, mailto cancel link. `/billing/plans` renamed from `/account/plans` (legacy URL becomes `<Navigate to="/billing/plans" replace />`). New "Billing" sidebar item (`CreditCard` icon). Admin panel: new "Subscription" card in widget inspector. Reciprocal cycle-ownership note added to `src/lib/usage/periods.ts` (anchor-based math will drift from billing periods after the first renewal that lands on a different calendar day — Feature 6 quota gate MUST read entitlement window from `subscription_usage_cycles` for subscribed tenants, NOT `currentPeriod`). Plan file: Feature 6 `CYCLE-SOURCE-OF-TRUTH LOCK` callout + Feature 2 status block. Migration 0012 NOT yet applied to dev/prod. |
| `bda2e20` | feat(pricing): dynamic geo-localized pricing module (Feature 1) + swarm-review patches — migration `0011_plans_pricing_extension.sql` extends `plans` with `price_inr/usd_monthly/yearly_cents`, `tagline`, `features`/`inclusions`/`entitlements` jsonb, `display_order`, `is_public`, `highlight`, `updated_at` (idempotent — every `ADD COLUMN` uses `IF NOT EXISTS`) + relabels seeded `basic`/`intermediate`/`advanced` → Starter/Business/Advanced with locked v1 pricing. Also adds `users.currency_preference` with `CHECK ('INR'\|'USD'\|null)`. New `src/plugins/geo.ts` lazy `resolveGeoForRequest(req)` helper using offline `geoip-lite` (called only inside `/api/plans`, NOT a global hook). `Fastify({ trustProxy: true })` enabled so PROD geo works behind cPanel nginx → Docker. New `GET /api/plans` (rate-limited 60/min per IP, `Cache-Control: private, max-age=60`) returns both currencies + features + inclusions + `displayOrder` + `highlight` with `detectedCountry`/`defaultCurrency` echo. **Entitlements stripped from public DTO** (Sec H1 fix — Feature 3 gate keys must never reach customer-facing JSON). Admin `/admin/plans` GET (full entitlements) + PATCH `/admin/plans/:id` (UUID validation, narrowed single-highlight transaction, reply sent OUTSIDE the tx so post-commit failures cannot ship phantom 200s). Admin panel: Plans button in topbar opens modal with list + inline-edit form + live JSON validation + transient "Saved" pill. New SPA `<PricingGrid>` (consumed by homepage Pricing / `/pricing` / dashboard `/billing/plans` with "Current plan" pill + legacy-plan callout + Upgrade vs Downgrade CTA branching via `me.planDisplayOrder`). **First-paint skeleton** prevents the `$` → `₹` flicker no-cookie India IPs would have seen. Single React Query key (currency switch is purely client-side — both currencies already in payload). `<CurrencySwitcher>` + `lib/format-price.ts` (en-IN Indian comma grouping `1,24,788`) + `lib/currency-storage.ts` (1-year `SameSite=Lax` cookie, `Secure` over HTTPS). Cross-device currency persistence deliberately deferred to Feature 10 (column exists; no endpoint reads/writes it yet — avoids shipping a typed `Me.currencyPreference` claim with no validation path). Plan file: Feature 9 `SECURITY LOCK` (checkout currency MUST resolve server-side from authenticated tenant; never trust `?currency=`/`?country=`/`aih_currency` cookie at money-bearing endpoints) + Feature 1 status block + GDPR escalation note (`/privacy-policy` SPA route is now also a Feature 1 EU-launch prereq — IP-derived country is personal data under GDPR Art. 4(1)). Migration 0011 NOT yet applied to dev/prod. |
| `6a2fe9d` | fix(security): never reflect raw server errors (SQL, stack traces) to UI — Drizzle's `Failed query: select ... params: ...` was leaking through `auth.routes.ts` (`err.statusCode ?? 500 / message: err.message`) → SPA's red `ErrorBanner`. Two-layer fix: backend `sendAuthError` helper + global `app.setErrorHandler` log unknown errors server-side and return a generic 500; SPA `api/client.ts` `sanitizeMessage()` strips SQL/stack/Drizzle/pg_/ECONNREFUSED markers + 5xx + oversized strings to a generic fallback before constructing `ApiError`, parks the raw text on `err.rawServerMessage` for DevTools-only access; `ErrorBanner` re-applies the check at render time and now accepts string\|Error\|unknown. Curated app errors (EMAIL_NOT_VERIFIED, RESET_TOKEN_EXPIRED, INVALID_STAFF_CREDENTIALS, Zod issues) still pass through unchanged. |
| `(uncommitted)` | feat(leads): tenant-scoped /api/leads + SPA /leads page — `0007_session_captures_name.sql` extends `session_captures.kind` CHECK to include `'name'` and adds `(tenant_id, captured_at DESC)` index. New `src/modules/leads/{leads.routes,leads.service}.ts` rolls captures up by `(provider_call_id, widget_id)` into one Lead row per call (`array_agg(value ORDER BY captured_at DESC)[1]` filtered by kind picks the LATEST per kind — NOT lexicographic MAX). Keyset pagination on `captured_at DESC`. New SPA `src/routes/LeadsPage.tsx` + `src/api/leads.ts`: infinite-scroll table, widget dropdown filter, date range, deep-link to `/widgets/:id/conversations?focus=:conversationId`. New "Leads" sidebar item between Widgets and Usage. |
| `(uncommitted)` | feat(admin): master ops impersonation — `0009_impersonation.sql` adds `users.is_staff boolean` (partial index where true) + new `impersonation_audit_logs` table (denormalised staff_email + target_email so the log stays readable years later). New `verifyStaffCredentials` in `auth.service.ts` (strict: is_staff + verified + valid password). New `src/modules/admin/impersonate.service.ts` and `POST /api/admin/impersonate` route — two-layer auth (admin key + staff email/password), audit row written BEFORE token is minted. JWT payload extended with optional `impersonatedBy: { staffUserId, staffEmail }` claim echoed by `/api/auth/me`. SPA: new `/auth/impersonate` landing (`ImpersonateLanding.tsx`, mirrors OAuthCallbackPage), sticky `ImpersonationBanner` mounted above `<Routes>` shown when `me.impersonatedBy` is set, "Exit impersonation" → logout. New `src/scripts/seed-staff.ts <email>` flips `is_staff=true` on an existing user (idempotent; warns if unverified or OAuth-only). |
| `(uncommitted)` | feat(usage): minute tracking + Retell reconciliation — new `0008_usage_tracking.sql` migration adds `tenants.subscription_started_at` + `usage_period_cache` table; new `src/lib/usage/{periods,recompute,read}.ts` library implementing cached-aggregate compute model (lazy recompute, never incremental); new `src/modules/usage/*` customer-facing API (`/api/usage/{current,history,daily,recompute}` + `/api/widgets/:id/usage/{current,history}`); new `src/modules/admin/reconcile.service.ts` + admin routes (`/retell-diff`, `/reconcile/{backfill,update-duration}`, `/tenants/:id/usage`) with audit trail to `provider_webhook_events`; new `src/providers/retell/retell.usage.ts` wrapping `client.call.list()` + `client.call.retrieve()` with pagination; SPA gets new `/usage` route, `UsageThisPeriodCard` on dashboard, `WidgetUsagePanel` on widget detail, `UsageBar` primitive ready for quota gating; admin panel inspector gets Usage + Reconciliation sections. See full session prose below. |
|---|---|
| `f827342` | feat(about): brand manifesto About page + frontend polish — full /about rewrite as 10-section manifesto (quiet → climax → quiet → personal → closing rhythm); Section 2 dual-panel composition with custom inline-SVG climax illustration; Framer Motion added (word-stagger fade-up, whileInView, staggerChildren, pathLength SVG draws, scroll-linked timeline); recurring YellowUnderline motif; AboutOrb (decorative-only); content in typed about.content.ts; MotionConfig reducedMotion="user". Shipped alongside: PublicLayout `'revealed'` → `'is-visible'` one-character reveal-class fix (unhides every `.reveal*` on every public route); ScrollToTop useLayoutEffect (PUSH/REPLACE only); all three HomePage orbs now call window.AIWWidget.open() with DemoModal fallback. |
| `e11332d` | feat(home): Voice chat widget features section + sync /how-it-works cards — new WidgetFeatures 3×3 grid between Differentiation and UseCases (pure-CSS animated icons, --font-display directly not legacy --font-sora alias). Replaces /how-it-works Steps section with the homepage's animated cards verbatim. `WidgetFeaturesFull` exported as a separate 9-card component for /how-it-works to reuse. |
| `cca3cbf` | feat(design-system): switch display + body fonts to Urbanist + DM Sans (minimal-diff `@import` + 3 `--font-*` tokens; old values preserved as commented rollback twins; `--font-sora` legacy token aliased to `var(--font-display)` for single source of truth) |
| `eb57683` | feat(ui): wire AI&Humn SVG wordmark into headers (landing TopNav at 32px; dashboard sidebar at 24px replacing circle+text; admin topbar at 24px replacing brand-mark+h1; width: auto preserves 6.27:1 ratio) |
| `e58a51a` | chore: add AI&Humn wordmark logo asset (#feca00 fill) — Monr-based geometric wordmark, 420×67 viewBox, dropped at `aiw-voice-app/src/design-system/assets/aih-logo.svg` and `aiw-voice-backend/admin/design-system/assets/aih-logo.svg` |
| `5c27888` | fix(seed): point seeded tenant at canonical 'basic' plan |
| `44f15ac` | feat(widget): M2 polish + anti-abuse + visitor identity + trigger safety (the full PR1–5 wave, transcript reconciler hardening, and trigger-engine empty-conditions guard, all squashed into one commit on `main`) |
| `6c3e373` | feat(retell): auto-sync agent webhook URL when linking agent (helper + auto-call in admin link-agent route + 2 recovery routes: per-widget `sync-webhook` and bulk `agents/sync-webhooks`) |
| `a5c26a5` | fix(landing): remove placeholder floating orb from HomePage (duplicated the real widget orb visually) |
| `f2c61ab` | fix(webhooks): use RetellAI's actual signature scheme + API key (delegate to `Retell.verify()` from SDK; switch from `RETELL_WEBHOOK_SECRET` to `RETELL_API_KEY` — Retell has no separate webhook-secret field) |
| `a5386da` | feat(app): auth-gate the marketing voice widget (script injection moved into React `<MarketingWidget />` reading `useAuth()`; on logout it removes script + orb host + `window.AIWWidget`) |
| `b740713` | feat(app): conversations page + dashboard wired to live stats API (`/widgets/:id/conversations` with sort, lazy-load, search, date filter, audio playback, transcript + details expanders; DashboardPage hits `/api/dashboard/stats`) |
| `48848e8` | feat(backend): conversations table + RetellAI webhook + dashboard stats API (migration `0003_cultured_fixer.sql`; HMAC-verified `POST /api/webhooks/retell`; conversations list with keyset pagination; dashboard stats aggregator) |
| `da9b547` | feat(app): /dashboard split + sidebar polish + stats overview (dashboard moved from `/` to `/dashboard`; sidebar `position: fixed` with longest-prefix isActive; new "Widgets" sidebar item → `/widgets`; same-origin `from` guard on login redirect) |

---

## Session: Widget anti-abuse Phase 1 + 4 (2026-06-10, commits `4099423` + `a1d20cc`)

Goal: stop bots / console scripts / orchestrated AI-voice callers from repeatedly opening the widget and draining a customer's Retell minutes. Planned as a layered defense in `.claude/plans/snappy-leaping-willow.md`; the user scoped it down to specific layers and asked for Phase 1 first, then "Phase 4" (= spike detection + alerts).

### How it was built
- **Recon workflow** (9 parallel Explore readers) mapped every integration point before writing code: `sendOpsAlert`, the F11 email engine, the cron plugin, the conversations table, the migration journal, admin routes + admin panel UI, the SPA widget-detail page, and the widgets pause mechanism.
- **Implementation** against the verified map.
- **Adversarial review workflow** (6 dimensions → 38 agents → 32 findings → 14 confirmed → triaged to 4 real fixes). False positives rejected with evidence.

### Phase 1 — front-door gates (`4099423`)
- **`event.isTrusted` gate.** `launcher.ts` rejects clicks where `!event.isTrusted` (programmatic `.click()` / `dispatchEvent`). `window.AIWWidget.open()` has no DOM event, so it's gated on a synchronous `inTrustedClickHandler` flag set inside the real click handler and cleared in a microtask — a bot calling `open()` from the console / a timer runs in a separate stack where the flag is false. BehaviorTracker auto-open (triggers) is deliberately NOT gated — there's no event to inspect; it's defended by the engagement gate instead.
- **Resolve→sessions HMAC challenge** (`src/lib/widget-challenge.ts`). `/api/widget/resolve` mints `base64url(payload).base64url(HMAC-SHA256(payload, WIDGET_CHALLENGE_SECRET))`, 90s TTL embedded in `iat`, widget_id-bound, NO IP binding (mobile cell↔WiFi switches). `/api/sessions` requires it as `X-AIW-Challenge`. Stateless (no Redis/DB). Closes the "curl /api/sessions with a leaked public widget_token + spoofed Origin" path.
- **Engagement gate.** The challenge payload carries `engagementSeen`; `POST /api/widget/engagement` re-mints the challenge with it true once the widget observes any of mousemove/scroll/keydown/pointerdown/touchstart/click/visibilitychange. Real visitors always trip it; a pure-passive headless bot never does.
- **Rollout**: `AIW_REQUIRE_CHALLENGE` + `AIW_REQUIRE_ENGAGEMENT` env flags, default `false` = shadow mode (log only). Plan: deploy shadow → bake 1 week watching logs → flip challenge → bake → flip engagement. Rollback = env var + restart. **Deploy prereq: `WIDGET_CHALLENGE_SECRET` (`openssl rand -hex 32`), independent of `JWT_SECRET`.**

### Phase 4 — spike detection + alerts (`a1d20cc`)
- **Migration `0025_spike_alerts.sql`**: `spike_alerts` table (`UNIQUE(widget_id, window_start)` = one alert per widget per clock-hour, the idempotency primitive); `widgets.auto_pause_reason` + `auto_pause_set_at` (customer self-pause markers, distinct from the F6 `service_status_override_*` admin columns); `conversations.request_ip` (captured at session-create via `req.ip` honouring trustProxy, feeds the top-IPs rollup); extends the `email_templates` event_key CHECK (drop + re-add) + seeds the `widget_spike_detected` template.
- **Detection cron** (`src/lib/cron/spike-detection.ts`, hourly, `CronLockKey.SPIKE_DETECTION = 0x7c4e_f9b2`): candidate = widgets with ≥ `AIW_SPIKE_FLOOR` (10) calls in the last rolling hour; baseline = `PERCENTILE_CONT(0.5)` of hourly counts over the prior 14 days (active hours only — conservative, biases the baseline up); `threshold = max(AIW_SPIKE_MULTIPLIER(2) × median, FLOOR)`; alert when `observed > threshold`. Idempotent `ON CONFLICT DO NOTHING RETURNING` — a returned row fires the ops + customer emails exactly once per hour. Same advisory-lock-TX + post-TX-dispatch pattern as F4 replenishment; all detection runs on `tx`, emails dispatched post-commit.
- **Ops notification**: `sendOpsAlert` gained an optional `recipientEmail` override → routes spike alerts to `OPS_SPIKE_ALERT_EMAIL` (default `updates@aiandhumn.com`) without touching the global `ADMIN_EMAIL` used by billing alerts. **Customer email**: new F11 lifecycle `notifyWidgetSpikeDetected` (`widget_spike_detected` event; idempotency key `widget_spike_detected:<widgetId>:<windowStart-iso>` — matches the per-hour spike row).
- **Admin dashboard**: new "Spike Alerts" LHS-nav page in `admin/index.html` — 5s poll guarded by `state.currentRoute` (self-cancels on nav-away), status filter, acknowledge button, red unread badge driven by the server's `unacknowledged` count. Backend `GET /admin/spike-alerts` (list+filter+paginate) + `PATCH /admin/spike-alerts/:id/ack` (`admin-spike-alerts.routes.ts` → `modules/spike-alerts/spike-alerts.service.ts`).
- **Customer SPA** (`components/WidgetActivityHealth.tsx`): amber spike banner above the widget-detail grid (one-click self-pause; dismissal persisted per `(widget, window)` in sessionStorage so a new spike re-shows) + an Activity Health card (recent alerts list with top-IPs + pause/resume). New JWT/tenant-scoped `GET /api/widgets/:id/spike-alerts` + `POST /pause` + `POST /resume`.
- **Self-pause security model** (hardened after review): customer pause sets `service_status_override='paused'` + `auto_pause_set_at`; admin pause leaves `auto_pause_set_at` NULL. **Both** customer resume AND customer re-pause refuse with 409 `ADMIN_PAUSED` when the widget is admin-paused — the pause guard mirrors the resume guard; without it a customer pause would clobber `service_status_override_by` and convert an admin pause into a customer-resumable one. The admin service-status endpoint now clears `auto_pause_*` on both set and clear, so a customer who self-paused first can't later lift an admin override.

### Review fixes applied (4 real, from 14 confirmed)
1. **Admin-pause clobber guard** — `pauseWidgetFromSpike` now 409s if the widget is already admin-paused.
2. **Admin endpoint hygiene** — `/admin/widgets/:id/service-status` clears `auto_pause_*` on both set and clear so the customer-resume guard can't be fooled.
3. **Schema↔SQL parity** — added `spike_alerts_status_check` to the Drizzle schema.
4. **UX** — banner dismissal persisted in sessionStorage.

Rejected false positives (with evidence): drizzle `numeric()` infers `string` in TS, so `String(median)` + `.default('0')` are correct (changing them would break tsc); the final INSERT needs no trailing `--> statement-breakpoint` (matches 0024); `.default('0')` ≡ SQL `DEFAULT 0`.

### Gotcha worth remembering
A review agent ran `npm run db:generate` to test for drift — it produced a ~20-table spurious diff (`0026_square_surge.sql` + a `meta/0026_snapshot.json`) AND reverted the `_journal.json` idx-25 entry. **`db:generate` is NOT this project's migration path** (migrations are hand-authored SQL + journal, applied by the hash-based self-healing migrator; the drizzle snapshot baseline was never maintained). The spurious files were deleted and the journal entry restored. Never commit `db:generate` output.

### Verification + deploy
- All three packages `typecheck` clean; admin inline JS parses (`node --check`-equivalent). No runtime deploy yet.
- Deploy: `cd /opt/aiw/{dev,prod} && git pull && docker compose up -d` (migrate runs `0025` first). **Set `WIDGET_CHALLENGE_SECRET` per env before deploy** (Phase 1 prereq). Optional Phase 4 env: `OPS_SPIKE_ALERT_EMAIL`, `AIW_SPIKE_MULTIPLIER`, `AIW_SPIKE_FLOOR` (all defaulted). Leave `AIW_REQUIRE_CHALLENGE`/`AIW_REQUIRE_ENGAGEMENT` false for the shadow-mode bake.

### Pending phases (not built)
- **Phase 2** — per-IP concurrent-session cap, locked to Option C (Postgres count query, NOT in-memory) for horizontal-scale correctness.
- **Honeypot** (5.5) — decoy endpoint + phantom `window.AIWWidget` method to log scanners.
- **Turnstile** (1.4) — Cloudflare invisible challenge, only if Phase 1+4 prove insufficient.

---

## Session: SEO/GEO/LLMO Audit — Sections 1-4 (2026-06-06 → 2026-06-09)

Four-section audit on the marketing site (`aiw-voice-marketing/`, deployed at `aiandhumn.com` prod + `aiworkfllow.com` dev) covering Technical SEO, Schema Markup, On-Page SEO, and LLM Searchability (AEO / GEO / LLMO). Run section-by-section under a strict per-finding triage protocol — short summary (audit report, change, effects, breakage risk, does-it-serve-the-purpose) BEFORE implementation, batched as one commit per section.

### Five commits landed on `origin/main`

1. `1be2e85` — T-01 hero lead primary-keyword injection (shipped alone — single-paragraph edit with no triage ambiguity)
2. `a8ea5c8` — Section 1 Technical SEO (T-02..T-06, T-09)
3. `1cbda0f` — Section 2 Schema Markup (S-01..S-04)
4. `657abdb` — Section 3 On-Page SEO (O-01..O-05)
5. `f967d31` — Section 4 LLM Searchability (L-01..L-06)

### Standing protocol locked during this audit

- Each audit finding gets a written triage summary; user approves explicitly before code lands. Quote from the user: _"Any audit i provide, is not an indication to implement right away. a more calculated and smooth way of implementation is what i expect... once the plan is approved by me and explicitly mentioned to implement it, you can implement."_
- One bundled commit per section
- Never act autonomously — surface follow-ups, never run them without explicit ask
- Cannot SSH to VPS (user runs commands and pastes output)

### Locked brand + metadata decisions (carry forward into ANY future session)

- `SITE_NAME = 'AI&Humn'` — no-space brand mark (final, locked T-03)
- `SITE_LEGAL_NAME = 'AI and Humn Technologies Pvt Ltd'` — ampersand spelled out per JSON-LD legalName requirements
- `SITE_DESCRIPTION` — 152-char meta-description form
- `SITE_LONG_DESCRIPTION` — `"AI&Humn is a voice AI widget for websites — whereby Visitors click, speak, and get answers grounded in your business and in real time."`
- HQ: Mumbai, India; no phone surface; `contact@aiandhumn.com` only
- Founded 2026
- All page metadata flows through `buildPageMetadata({path, title, description})` + `buildAlternates(path)` in `lib/site.ts`
- `title.template` = `'%s — AI&Humn'` in `app/layout.tsx`. Per-page `title: { absolute: '...' }` is rendered verbatim (used for SEO-tuned titles per O-01)
- FAQPage JSON-LD shipped via `buildFaqLd(items)` in `lib/faqs.ts`. Homepage = `HOMEPAGE_FAQ_ITEMS` from `components/home/SectionsB.tsx`. Pricing = `BILLING_FAQ` from `app/pricing/page.tsx`. Visible accordion + schema read from same source — no drift possible

### Claim discipline (LOCKED — must NOT revive without measured backing + CLAUDE.md update first)

- `HIPAA-ready` → `"Enterprise-ready deployments include audit logs and per-tenant isolation"`
- `99.97% uptime` → REMOVED (not a measured SLA)
- `240+ sites live` → REMOVED (no measured number behind it)
- `English, Hindi, mixed` / `EN · HI` → `"English with strong handling of global accents"` (Hindi not live)
- `RBI-aligned` → REMOVED (fintech use-case note now enterprise-framed)
- `Avg time-to-first-word: 1.8s` / `under two seconds` → soft `"low latency"` framing on every marketing surface EXCEPT homepage FAQ Q5 which keeps `"Less than 0.6 sec"` as the one specific number for AEO retrieval (Q→A pairs are the unit of structured-data retrieval; one concrete number in the FAQ pays off without claiming it everywhere)

### Per-section close-out

**Section 1 — Technical SEO (T-01..T-09).** T-01 = primary keyword in hero lead (early commit `1be2e85`). T-02 = 152-char meta description. T-03 = locked `SITE_NAME = 'AI&Humn'` (no-space) and introduced `buildPageMetadata` helper. T-04 / T-05 = per-page OG / Twitter via the helper (was inheriting homepage values across all routes). T-06 = sitemap lastmod infrastructure built (`scripts/generate-lastmod.mjs` + `lib/lastmod.generated.json` + `app/sitemap.ts`) but non-functional in Docker (no `.git` / no `git` binary in container) — won't-fix; infra left in place for future non-Docker hosting. T-07 (`/blog`) deferred. T-08 (body-copy brand standardization) intentionally deferred — `SITE_NAME` is no-space, body prose still mixed. T-09 = Mumbai / India address only, no phone.

**Section 2 — Schema Markup (S-01..S-05).** S-01 = full Organization JSON-LD in `app/layout.tsx` (`@id`, founder Dhruv Dholakia + sameAs LinkedIn + X, PostalAddress Mumbai/IN, foundingDate 2026, legalName `"AI and Humn Technologies Pvt Ltd"`, description = `SITE_LONG_DESCRIPTION`). S-02 = WebSite JSON-LD with publisher `@id`-linked to Organization (Option A — single source of truth for org references). S-03 = SoftwareApplication JSON-LD with AggregateOffer array carrying both currencies (`PRICING_RANGE_INR = { lowPrice: '1999', highPrice: '12999', offerCount: '3' }`, `PRICING_RANGE_USD = { lowPrice: '40', highPrice: '250', offerCount: '3' }`), 8-item featureList (Hindi dropped per claim discipline). S-04 = FAQPage JSON-LD on homepage + pricing via `buildFaqLd`. S-05 (BreadcrumbList) deferred until URL structure deepens.

**Section 3 — On-Page SEO (O-01..O-05).** O-01 = six SEO-optimized titles via `title: { absolute: '...' }`:
- `/about` → `"About AI&Humn — Voice Conversion Layer for the Web"`
- `/how-it-works` → `"How to Add a Voice AI Widget to Your Website — AI&Humn"`
- `/pricing` → `"Voice AI Widget Pricing Plans — From $40 (₹1,999)/mo — AI&Humn"`
- `/security` → `"Security & Compliance — GDPR, DPDP, Enterprise-Ready — AI&Humn"` (HIPAA dropped)
- `/contact` → `"Contact AI&Humn — Support, Partnerships & Press"`
- `/investors`, `/privacy-policy`, `/terms` keep simple templated titles

O-02 = DOM-text word-boundary fix on word-stagger Framer Motion animations in `HiwHero.tsx` + `AboutHero.tsx`. Each word now wrapped as `<Fragment key={i}><motion.span variants={word}>{w}</motion.span>{i < words.length - 1 && ' '}</Fragment>` (literal space between spans). Old inline `marginRight: '0.3em'` dropped from `styles/about.css` + components. Why: CSS-margin spacing meant the rendered text node had NO whitespace between words — copy/paste of the hero gave a concatenated string, and screen readers / search-engine text extractors saw one giant word.

O-03 = `availability: 'https://schema.org/InStock'` on each Offer in the pricing ItemList JSON-LD (USD + INR). Required by Google Merchant guidelines for Offer schema.

O-04 = `/blog` deferred.

O-05 = full claim-set cleanup across 8 surfaces — STATS marquee, hero caption (`EN · HI` → `EN`), healthcare + fintech use-case notes (now `"Enterprise-ready deployments with audit logs."`), DataHandling line, homepage FAQ Q6, about principle (`"Compliance is table stakes."` → `"GDPR-aware, India DPDP-aligned, multi-tenant from day one."`), investors METRICS card (Agent uptime 99.97% + Sites live 240+ removed; Founded 2026 + HQ Mumbai, India added).

**Section 4 — LLM Searchability / AEO / GEO / LLMO (L-01..L-06).**

L-01 = `aiw-voice-marketing/public/llms.txt` (Next.js public dir serves at `/llms.txt`). Body uses `"Low Latency for real-time conversations"` (audit-suggested `"Avg time-to-first-word: 1.8 seconds"` line replaced per user direction — and the broader latency claim refresh below kept the soft framing consistent).

L-02 = closed as duplicate of S-04 (FAQPage JSON-LD already shipped).

L-03 = `/blog` cornerstone content deferred. Day-one targets when blog launches: `/blog/what-is-a-voice-ai-widget` + `/blog/how-voice-ai-improves-website-conversion`.

L-04 = dev-env noindex shipped here. Bing Webmaster registration + IndexNow code DEFERRED — code queued behind user's out-of-band Bing registration.

L-05 / L-06 = parked as non-code growth tracks (Product Hunt, G2/Capterra/Trustpilot listings, guest posts on MarTech blogs, HARO/Connectively responses, Reddit AMAs on r/SaaS + r/webdev + r/artificial).

Also bundled into Section 4's commit:

**Latency claim refresh.** Specific 1.8s number replaced with soft `"low latency"` framing on all 7 marketing surfaces — STATS marquee, Differentiation table, FAQ Q5, investors METRICS card, `/how-it-works` "Under the hood" section lead, `/how-it-works` Step 04 body, `/how-it-works` SEO meta description. Homepage FAQ Q5 is the ONE exception, keeping `"Less than 0.6 sec"` as a concrete answer for AEO retrieval.

**Dev-env noindex (A + B).** A) Dev `robots.txt` at `deploy/cpanel-docroots/aiworkfllow/robots.txt` with `User-agent: *` + `Disallow: /`. Installs at `/home/aiworkfllow/public_html/robots.txt`. Same cPanel-shadow pattern as the prod robots.txt — drops in docroot because EA-nginx auto-generates `location = /robots.txt` at server scope which cannot be overridden from a user-include (parse-time "duplicate location" error). B) `X-Robots-Tag: noindex, nofollow` header on every location in all 4 dev nginx user-includes (`deploy/cpanel-nginx/aiworkfllow/{aiworkfllow.com,app.aiworkfllow.com,api.aiworkfllow.com,widget.aiworkfllow.com}/proxy.conf`) with the `always` flag so 301 redirects also carry the directive. PROD (`aiandhumn`) user-includes DELIBERATELY untouched — `aiandhumn.com` stays the canonical, indexable domain.

### Deploy state

All 5 SEO commits + hotfix `3c7ff0a` live on `origin/main`. Marketing rebuilt + deployed to DEV + PROD — both verified `200` after the hotfix. **Phase 3 (dev nginx noindex install) completed 2026-06-09** — `aiworkfllow.com` + subdomains now emit `X-Robots-Tag: noindex, nofollow` and serve `Disallow: /` at the apex `robots.txt`; PROD stays indexable. Three-phase runbook preserved below for future reference.

### Hotfix recovery (`3c7ff0a`)

First deploy of Section 2's S-04 FAQ JSON-LD wiring took DEV + PROD homepage 500 simultaneously. Root cause: `HOMEPAGE_FAQ_ITEMS` was exported from `components/home/SectionsB.tsx` (a `'use client'` accordion using `useState`) and imported by `app/page.tsx` (a Server Component) to serialize FAQPage JSON-LD. Next.js's RSC compiler wraps every export from a `'use client'` module as a client-reference Proxy when consumed by a Server Component — calling `.map()` on the Proxy throws at render time with `"Attempted to call map() from the server but map is on the client."` (Digest `330002672`). Pricing was unaffected because `BILLING_FAQ` lives in the same Server Component file (no boundary crossed).

Fix (3 files, no shape changes): moved `HOMEPAGE_FAQ_ITEMS` to `lib/faqs.ts` (server-safe module alongside `buildFaqLd`); both `SectionsB.tsx` (client accordion) and `app/page.tsx` (Server Component JSON-LD) now import from there. Visible accordion + schema JSON-LD still drift-locked (same source array). RSC-boundary lesson codified as a Key Convention in CLAUDE.md so future shared-data exports go in `lib/*.ts` (server-safe) and not into a `'use client'` file.

### Three-phase deploy runbook (user runs as root on VPS)

```bash
# Phase 1: pull
cd /opt/aiw/src && git pull origin main

# Phase 2: marketing redeploy (carries llms.txt + latency-claim refresh + per-page metadata across Sections 1-4)
cd /opt/aiw/dev  && docker compose build marketing && docker compose up -d --no-deps marketing
cd /opt/aiw/prod && docker compose build marketing && docker compose up -d --no-deps marketing

# Phase 3: dev noindex one-time setup (Section 4 only)
install -m 644 -o aiworkfllow -g aiworkfllow \
  /opt/aiw/src/deploy/cpanel-docroots/aiworkfllow/robots.txt \
  /home/aiworkfllow/public_html/robots.txt
cp /opt/aiw/src/deploy/cpanel-nginx/aiworkfllow/aiworkfllow.com/proxy.conf       /etc/nginx/conf.d/users/aiworkfllow/aiworkfllow.com/proxy.conf
cp /opt/aiw/src/deploy/cpanel-nginx/aiworkfllow/app.aiworkfllow.com/proxy.conf   /etc/nginx/conf.d/users/aiworkfllow/app.aiworkfllow.com/proxy.conf
cp /opt/aiw/src/deploy/cpanel-nginx/aiworkfllow/api.aiworkfllow.com/proxy.conf   /etc/nginx/conf.d/users/aiworkfllow/api.aiworkfllow.com/proxy.conf
cp /opt/aiw/src/deploy/cpanel-nginx/aiworkfllow/widget.aiworkfllow.com/proxy.conf /etc/nginx/conf.d/users/aiworkfllow/widget.aiworkfllow.com/proxy.conf
nginx -t && systemctl reload nginx

# Verify
curl -s https://aiandhumn.com/llms.txt | head -3                                       # llms.txt live
curl -s https://aiworkfllow.com/robots.txt                                              # "Disallow: /"
curl -sI https://aiworkfllow.com/                              | grep -i x-robots-tag   # noindex, nofollow
curl -sI https://app.aiworkfllow.com/login                     | grep -i x-robots-tag   # noindex, nofollow
curl -sI https://api.aiworkfllow.com/admin/                    | grep -i x-robots-tag   # noindex, nofollow
curl -sI https://widget.aiworkfllow.com/aiw-voice-widget.js    | grep -i x-robots-tag   # noindex, nofollow
curl -sI https://aiandhumn.com/                                | grep -i x-robots-tag   # MUST be empty — prod stays indexable
```

### Parked / deferred items (handoff to future sessions)

- **T-06 sitemap lastmod** — won't fix in Docker; infrastructure inactive but left in place for future non-Docker hosting
- **T-07 / O-04 / L-03 `/blog` launch** — separate effort. When started, day-one cornerstone pages should be `/blog/what-is-a-voice-ai-widget` and `/blog/how-voice-ai-improves-website-conversion`
- **T-08 body-copy brand standardization** — `SITE_NAME` is the no-space form, but Hero / About / Investors body copy still uses "AI & Humn" with spaces in places. Intentionally mixed today
- **S-05 BreadcrumbList** — defer until nested URLs exist
- **L-04 Bing Webmaster + IndexNow** — code queued behind user's out-of-band Bing registration. After Bing account + API key, the IndexNow integration is a small post-deploy script ping
- **L-05 backlinks / external citations** — non-code growth track (Product Hunt, G2/Capterra/Trustpilot listings, guest posts on MarTech blogs, HARO/Connectively responses)
- **L-06 Reddit searchability** — non-code growth track (r/SaaS, r/webdev, r/artificial active presence)
- **Legal-entity rename in backend** — `tax_profiles` (and any other backend reference to the company legal name) may need updating to `"AI and Humn Technologies Pvt Ltd"` operationally. Separate from marketing SEO scope
- **Review JSON-LD** — pending real customer references; do NOT ship fake testimonials

### Working-tree state at session end

Branch `claude/inspiring-jemison-7483ac` at `f967d31`. `origin/main` also at `f967d31` (fast-forwarded in the same push). One untracked file (`aiw-voice-marketing/package-lock.json`) remains — predates this audit, not part of the SEO work; leave for a separate triage.
## Session: Pricing Flow F1-F11 — Post-Deploy Polish (2026-06-02 → 2026-06-04, commits `6b0e036` → `aa75be2`)

Ten follow-up commits on `main` after the 2026-06-01 DEV deploy of `5ca3ddc`. **Net theme:** the per-feature swarm reviews are great at catching server-side bugs but they miss admin-panel JS errors (the panel is static HTML — `tsc` doesn't see it, and "load the panel in a browser" wasn't on the swarm-review checklist) AND runtime-only races between async data loaders and global observers. Almost every one of the FIX commits is a class of bug that would have been caught by a 60-second panel + homepage smoke test. The FEATURE commits build out the Tenants page as the single conceptual home for tenant-scoped resources.

### What changed

| # | Commit | Class | Impact before fix |
|---|---|---|---|
| 1 | `6b0e036` | docs+chore | (deploy capture; `db:status` now invokes compiled JS) |
| 2 | `e2e8ef9` | Parser-killing SyntaxError | Every event listener silently failed to attach — page rendered as inert HTML. |
| 3 | `0b71679` | Widget convention drift + missing persistence | Backend `err.message` reflected to visitor transcript (violated "never reflect raw errors"); quota cooldown reset on reload. |
| 4 | `23a6df8` | **Critical data corruption** | Saving any Plan zeroed all unspecified prices + limit columns. Whatever the real values were is gone. |
| 5 | `0d6d470` | Raw SQL type mismatch | Every Coupons GET threw `uuid = text` operator-not-exists (migration 0016 seeds WELCOME20 so it fires immediately). |
| 6 | `49cd2f1` | Undefined-constant ReferenceError | Email tab completely dead from first open. |
| 7 | `1439050` | Response-shape mismatch | 6 list views always rendered "No X yet." even with rows in DB. |
| 8 | `84e6192` | Feature — admin sidebar nav + Tenants page | (constructive, not a fix) |
| 9 | `d90fa39` | Refactor — conceptual relocation | Plan + top-up mutations moved off widget inspector onto Tenants page (where tenant-scoped resources belong). |
| 10 | `0929f2a` | Feature — per-tenant usage observability | (constructive — closes the "what is this tenant actually consuming?" question for ops at the tenant level) |
| 11 | `aa75be2` | Observer-vs-async-data race | Homepage Pricing section silently blank on first paint; nav-away-and-back made it appear (React Query cache hit skipped the skeleton). |

### Detail per commit

#### `6b0e036` — Deploy capture + `db:status` fix
Documented the 2026-06-01 DEV deploy of `5ca3ddc` (16 commits `d1256c5 → 5ca3ddc`, 14 migrations `0011 → 0024` clean). Updated `deploy/pricing-flow-deploy-queue.md` ledger. **Bonus fix:** `npm run db:status` in `package.json` was calling `ts-node` directly, but the runtime image strips it via `npm ci --omit=dev` — workaround during the deploy was `docker compose run --rm api node dist/scripts/db-status.js`. Now the script points at compiled JS.

#### `e2e8ef9` — Set-key button + all admin actions
**Symptom:** every event listener in `admin/index.html` silently failed to attach. Page rendered as static HTML; nothing clickable did anything. Live on DEV (https://api.aiworkfllow.com/admin/).

**Root cause:** the polished F2 admin Subscription-card insert (shipped via 90bd310 → carried verbatim into 75be9a6 / 5ca3ddc) included an HTML comment containing `\`plans\`` — backticks and all — INSIDE a JS template literal in `renderInspector()`. Template literals don't honour HTML comment syntax, so the inner backticks terminated the outer literal early. Parser then hit "plans" as a bare identifier, threw SyntaxError, and abandoned the ENTIRE `<script>` block.

Three other similar-looking spots (lines 1585 / 1838 / 2094) are inside real JS `//` or `/* */` comments where backticks are harmless characters — verified safe via `node --check` post-fix.

**Fix:** replace the backticks with single quotes in the comment text.

**Why we didn't catch it:** `tsc` doesn't see `admin/index.html` (static HTML), and the F6/F10/F11 swarm review didn't include a "load the panel in a browser" verification step. **Added `node --check` to the post-fix verification.** Folding it into the swarm-review checklist is a separate process change.

#### `0b71679` — F6-W4 + F6-W6 widget gaps
**F6-W4 (visitor copy at the wrong layer).** Previous code at `widget.ts:477` passed backend `err.message` verbatim to the visitor transcript (with a generic fallback). The UX P1-2 spec copy was patched at the BACKEND error message but the WIDGET still trusted that text — violating the CLAUDE.md "never reflect raw server errors to the UI" convention and leaving the visitor exposed if any code path bypassed the curated message.

**Fix:** module-scope `QUOTA_VISITOR_COPY` table keyed on `err.code` with per-code neutral wording. Backend `err.message` is now DELIBERATELY IGNORED for the visitor transcript. Three variants:
- `SERVICE_PAUSED` / `PAYMENT_FAILED` → full UX P1-2 copy ("This voice assistant is paused right now. Please reach out to the site team directly, or try again shortly.")
- `QUOTA_EXCEEDED` → shorter "try again shortly" (top-up may land any moment)
- `NO_SUBSCRIPTION` → "isn't currently available" (less likely transient)

This single change also lifts F6-DEFERRED-C from "partial" to "shipped" since the 4 codes now map to distinct visitor copy.

**F6-W6 (sessionStorage cooldown missing).** Prior code at `widget.ts:468-488` kept the quota cool-down in memory only. A page reload bypassed the disable. Now persisted via `sessionStorage` keyed by widget token, with `applyQuotaCooldown()` called in `init()` to re-apply the disable on next load if still within the window. Mirrors the existing `applyRateLimitCooldown()` pattern from M2 PR4.

#### `23a6df8` — Critical: plan-save zeroed unspecified prices
**Symptom:** open admin Plans editor, see `0/0/0/0` for all prices, type new value into INR-monthly only, save. Backend persists zeros to USD-monthly + INR-yearly + USD-yearly. **Whatever the real values were is gone.**

**Root cause:** the admin Plans editor (`admin/index.html` `planEditFormHtml` + `planRowHtml`) reads flat fields:
```
p.priceInrMonthlyCents, p.priceUsdMonthlyCents,
p.priceInrYearlyCents, p.priceUsdYearlyCents,
p.maxMinutesMonth, p.maxWidgets, p.maxKbBytes
```

But GET `/admin/plans` returns the public-DTO-spread admin shape from `reshapeForAdmin`, which inherits the nested layout from F1's `reshapeForPublic`:
```
{ prices: { inr: { monthly, yearly }, usd: { monthly, yearly } },
  limits: { widgets, minutesMonth, kbBytes }, ... }
```

The flat fields are entirely absent from the response. So in the editor: `p.priceInrMonthlyCents` → undefined → `num(undefined)` → `Number.isFinite(undefined) === false` → returns 0 → form input renders `value="0"`. `planRowHtml` row summary computes `undefined/100` → `NaN/100` → "₹NaN/mo".

Save flow: admin types `250000` into INR-monthly only, clicks Save. FormData reads back `"0"` for the three untouched fields. Body posts `{priceInrMonthlyCents:250000, priceUsdMonthlyCents:0, priceInrYearlyCents:0, priceUsdYearlyCents:0}`. Backend Zod validates (0 is nonnegative). UPDATE persists zeros.

**Fix:** patch the editor to read nested (`p.prices?.inr?.monthly` etc.) + write back nested + render `₹X/mo` correctly.

#### `0d6d470` — Coupons admin: raw `ANY()` on `uuid` column
Two issues compounding into the "Something on our end is misbehaving" 500 on both Coupons list and Create:

1. **Critical SQL bug** — `admin-coupons.routes.ts:233` used raw `sql\`${couponRedemptions.couponId} = ANY(${ids})\`` to fetch per-coupon redemption counts. Drizzle/postgres-js binds the JS `string[]` as a Postgres `text[]` parameter, but `couponRedemptions.couponId` is `uuid`. Under PG 14+ there is NO implicit cast for `uuid = text`, so the query throws "operator does not exist: uuid = text" the moment ANY coupon row exists. Migration `0016` seeds WELCOME20 so the bug fires on first GET in every deploy.

   Every other coupon-related file in the codebase uses drizzle's `inArray()`; this one route was an outlier. Switched to `inArray(couponRedemptions.couponId, ids)` which generates a properly typed `coupon_id IN ($1, $2, ...)` predicate.

2. **Diagnostic gap** — the previous catch blocks on POST/PUT/PATCH/GET only handled 23505 (duplicate code); everything else fell through to `throw err`, which the app-level setErrorHandler swallowed to the generic banner. Expanded catch blocks log real errors so the next time this class of bug hits, the message tells us what's wrong.

#### `49cd2f1` — Email tab + topup-sku error logging
Two distinct fixes:

1. **Email automation tab — "Load failed: API_BASE is not defined"**

   Five fetch() calls in the F11 email module (`loadEmailTemplates`, per-template save/preview/test-send, `loadEmailLog`) referenced an undefined `API_BASE` constant. The actual top-of-script constant is `API = '/api/admin'`. Hitting Refresh or even opening the Email tab threw `ReferenceError: API_BASE is not defined` immediately.

   Compounding bug: the broken paths included a redundant `/admin/` prefix (the author wrote `API_BASE + '/admin/email-templates'` assuming `API_BASE` was `/api`). Fixing `API_BASE → API` blindly would have produced `/api/admin/admin/email-templates` (double-/admin), which the backend doesn't route. Replaced the FULL fragment instead:
   ```
   API_BASE + '/admin/email-templates...'   →   API + '/email-templates...'
   API_BASE + '/admin/email-log?...'        →   API + '/email-log?...'
   ```
   Backend mounts these routes via `admin.routes.ts` with prefix `/api/admin`, so the resolved URLs are `/api/admin/email-templates`, `PATCH /api/admin/email-templates/:id`, etc.

2. **Topup-SKU 500s now log real errors** — same diagnostic-gap fix as `0d6d470`; previously errors fell through to the generic banner.

#### `1439050` — `json.data.X` against flat `ok()` response
Six locations in `admin/index.html` parsed responses as `json.data.X` but the backend `ok()` helper (`src/lib/response.ts`) spreads payload INTO the top level:
```js
ok({skus: [...]}) → {ok: true, skus: [...]}   // flat, NOT nested
```

Every consumer that read `json.data.skus` got `undefined` → short-circuited to `[]` → rendered "No SKUs yet." even though tables had rows.

Affected:
- `loadTopupSkus` → `json.data.skus` → can't see any SKUs (3 seeded by migration 0020 + user's `basic` SKU)
- `loadEmailTemplates` → `json.data.templates` → modal blank
- email preview → `rj.data.rendered` → preview throws
- email test-send → `rj.data.logId` → success msg shows `undefined`
- `loadEmailLog` → `json.data.rows` → log throws TypeError
- `loadEmailLog` row count → `json.data.limit` → footer throws

Switched all 6 to top-level access. The `data: {...}` envelope was never the actual shape, but copy-paste momentum carried it everywhere.

#### `84e6192` — Admin sidebar nav + Tenants page
**Constructive change** (not a fix). Replaces the "topbar buttons opening modals" pattern with persistent left-rail nav. Menu items: Widgets / Tenants / Plans / Top-up SKUs / Coupons / Tax & invoices / Email automation. Topbar keeps only Set Key + Refresh.

**Hash router** (`#widgets` default, `#widgets/<id>`, `#tenants`, `#tenants/<id>`, `#plans`, `#tax`, `#coupons`, `#topup-skus`, `#email`) survives reload + back-button.

**Widgets page:** status filter dropdown (was button column) + proper data table. Click row → inspector mounts inline below with "← Back to widgets list" affordance.

**New Tenants page:** search + table (Owner / Plan / Widget count / Created) → click row → detail card with:
- Owner info (email, created)
- Current subscription card + plan snapshot + cycle row
- Widgets list (each with "Open widget →" deep-link)
- Recent top-ups
- Recent invoices

**5 catalog modals migrated to sidebar pages:** modal-bg wrappers neutralised via CSS `body.routed` selectors; inner content moved into page bodies at boot via `migrateModalsToPages()`; element lookups inside helpers refactored to lazy `_plans()` / `_tax()` / `_cpn()` / `_ts()` getters so post-move identities resolve. Form / refresh / reset listeners wire-once-guarded so re-navigation doesn't double-attach.

#### `aa75be2` — Homepage Pricing section stays blank on first paint
**Symptom.** On a hard load of `/`, the Pricing section renders empty. Navigating away and back makes the section appear.

**Root cause.** `HomePage.tsx` mounts a single global `IntersectionObserver` in a `useEffect` that runs ONCE on mount. It calls `document.querySelectorAll('.reveal, .reveal-stagger').forEach(observe)` right after a double-rAF. At that moment `PricingGrid` is rendering its **loading skeleton** — and the skeleton carries the same `.reveal-stagger` class as the real grid (so the layout is consistent across both states). The observer attaches to the SKELETON node.

When `/api/plans` resolves, React unmounts the skeleton and mounts the real grid — a brand-new DOM node the observer never attached to. The `.reveal-stagger > *` default (`opacity: 0`) stays applied forever and the section renders blank.

**Why return navigation works.** React Query's cache delivers data synchronously on a return visit, so `PricingGrid` skips the skeleton state and mounts the real grid immediately. `HomePage`'s observer attaches to the real grid first try.

**Fix.** A small `RevealGrid` wrapper that self-promotes to `is-visible` via `useEffect` + ref on mount. Doesn't depend on the parent observer's attach timing at all. The `.reveal-stagger > *` staggered-children animation still triggers correctly once the `is-visible` class lands on the parent.

Applies to `/pricing` too (same component) — there the existing parent observer would catch the real grid in most cases, but the self-promote makes the behaviour consistent across all `PricingGrid` mount sites.

**Pattern lesson worth remembering** (and applicable to any future async-data-loader-vs-global-observer site): a `IntersectionObserver` that walks the DOM at mount time will miss any element that an async data loader mounts AFTER that walk completes. Two correct patterns:

1. **Have the receiving component self-promote** — pass a ref, `useEffect` to add `is-visible` on mount. Simplest, works for any data shape. This is what `RevealGrid` does.
2. **Re-query + attach when data lands** — call the observer's `observe()` again from whatever `useEffect` depends on the data. Requires lifting the observer ref or exposing a "re-scan" method.

Don't rely on a single-pass `forEach(observe)` from the parent if any child mounts asynchronously.

#### `0929f2a` — Usage by cycle card + detailed usage history on Tenants page
**Context.** After `d90fa39` made the Tenants page the conceptual home for tenant-scoped resources (plan + top-ups), the missing piece was answering "what is this tenant actually consuming?". The customer-facing `/usage` page already exists for the tenant themselves, but ops staff need cross-tenant visibility without resorting to direct DB queries.

**Two new admin endpoints** in `admin.routes.ts`:

1. **`GET /admin/tenants/:tenantId/usage-by-cycle?limit=5`** — returns the last N `subscription_usage_cycles` for the tenant, each with `minutes_used` + `call_count` aggregated from `conversations` via LATERAL join on the cycle window. One round-trip query.

   **IMPORTANT — aggregates directly from `conversations`, NOT from `usage_period_cache`.** The cache is anchored on the tenant's 30-day anniversary (the F2 `subscription_started_at` math), which diverges from subscription cycles after any plan change. Per the **`CYCLE-SOURCE-OF-TRUTH LOCK`** documented in `subscription.service.ts`, for subscribed tenants the entitlement window MUST be read from `subscription_usage_cycles`. So this endpoint walks the cycle table directly and lateral-joins `conversations` to recompute minutes per cycle window. Slower per-call than reading the cache, but correct under plan changes (which the cache is not).

2. **`GET /admin/tenants/:tenantId/usage-history?from=&to=&type=&page=&pageSize=`** — unified chronological feed of subscription cycles + top-ups, filterable. Strategy: two queries (cycles + topups) filtered server-side, merged + sorted + paginated in Node. Avoids union-projection pain; expected volume (≤500 rows per tenant for years) makes this comfortable.

**New "Usage by cycle" card** on tenant detail (below Subscription & plan). Renders: period / plan / used-vs-included progress bar / topup contribution / status. **Color thresholds match F4 customer dashboard:** green <75%, amber 75-100%, red ≥100%. Empty state when no cycles exist. "View detailed history →" CTA navigates to the new sub-route.

**Hash router enhancement** to support a 3-part path: `route/param/subroute` plus an optional `?qs` tail. `parseHash` + `navigateTo` updated. Existing 2-part routes unchanged.

**New sub-route `#tenants/:tenantId/usage`** with its own page section. Filter bar: from / to dates + type (both | subscription | topup) + page size + Apply / Reset / Back. Unified table rendering both subscription_cycle and topup rows with the same Used/Total + status treatment. Pagination footer with Prev / Next + "Showing X-Y of Z". **Filter state persists in URL query string** (survives reload + back button + bookmarking).

**Auto-reload contract:** the card refreshes after any tenant-level mutation (assign-plan / unassign / grant-topup / refund / force-tick) because `loadTenantOverview` re-runs `renderTenantDetailHtml` which re-mounts the empty card div, and `loadTenantUsageCard` then re-fetches.

**Edge cases handled inline:**
- Tenant has never subscribed → empty card with hint
- Pre-migration-0022 cycles (no `topup_minutes_debited` column) → topup column renders `—` (we never imply zero)
- Refunded topups → row dimmed with strikethrough usage column
- Cycle status pill: `ACTIVE` green, `CLOSED` muted
- Plan changed mid-cycle: each cycle row carries its own frozen `plan_snapshot` — no special-case code needed
- Filter `from > to` → backend returns `400 BAD_RANGE`
- Page beyond last → clamped to last valid page server-side

**Verified:** backend `tsc --noEmit` clean; admin JS parses clean (238,350 chars). No migrations, no new dependencies, no env changes. Two files modified (`admin.routes.ts`, `admin/index.html`).

#### `d90fa39` — Tenant-level mutations off widget inspector
**Refactor** prompted by the sidebar restructure above. Plans and top-ups are tenant-scoped resources — both live on the tenant, not the widget. `84e6192` put the assign-plan form, unassign button, force-tick/cancel-pending buttons, and the entire top-ups card (with grant form + refund buttons) inside the widget inspector. That made the widget inspector the de-facto control plane for tenant-level concerns, which is the wrong conceptual home — and duplicated UI vs the new Tenants page.

**This commit moves every mutation to the Tenants page detail card:**

| Widget inspector (now READ-ONLY for tenant concerns) | Tenants page → tenant detail card |
|---|---|
| Subscription card: plan name + status + period + cycle + billing history + change events, with deep-link "open Tenants page → this tenant" via hash router. | Subscription & plan card: same data display PLUS action buttons (Force replenishment tick, Cancel pending change when applicable, Unassign plan) + Assign/Switch plan disclosure form. |
| Top-ups card: REMOVED entirely. | Top-ups card: full grant form + refund per-row buttons. |
| Quota card: kept (runtime widget-level diagnostic). | — |

### Open process item

**Swarm-review checklist update needed.** The three-agent swarm pipeline (Architecture / Security / Product-UX) currently flags backend correctness but doesn't include a panel smoke test. Two-line addition:
1. After any commit that edits `admin/index.html` or `aiw-voice-widget/src/`, run `node --check admin/index.html` + open the panel in a browser + click the affected tab.
2. After any commit that edits a public DTO shape, grep callsites for the previous shape and update consumers in the same commit.

---

## Session: Pricing module Features 3-8, 10, 11 (2026-05-30 → 2026-05-31, all committed)

**Scope:** the eight Pricing Module features that landed in rapid succession on `main` between F2 and the F11 lock-in. Each was built in the standard 6-chunk pattern (schema/migration → backend service/route → SPA helpers → public surface → dashboard surface → admin surface) + 3-agent swarm code review (Architecture + Security + Product/UX) → arbiter dedup → fixes applied.

Per-feature SHA + headline-decisions table:

| F | SHA | Decision-set | Notable LOCK |
|---|---|---|---|
| **F7** Tax | `0fee171` | #12 Paddle MoR for non-INR, in-house GST for INR | `F7 SECURITY LOCK` (tax-country pinned to subscription currency after first paid period), `F7 ORCHESTRATOR-TAX CONTRACT` |
| **F8** Checkout + coupons | `eac88a7` | #6 gateway-agnostic coupons | `F8 SECURITY LOCK` (re-validate coupon discount + tax against LIVE prices in webhook) |
| **F4** Replenishment cron | `3f88fa6` | #8 monthly minute refresh regardless of billing cadence | `F4 ENTITLEMENT-NARROWING LOCK` (cron NEVER narrows entitlement), `F9 CLOSE-OLD-CYCLE CONTRACT` |
| **F5** Top-ups | `4be9a1e` | #13 12-month per-pack expiry, #9 FIFO | `F5 SECURITY LOCK` (re-validate against LIVE SKU price in webhook) |
| **F6** Quota gating | `90bd310` | #14 30-min hard cap on session duration | `F6 SECURITY LOCK` (service_status server-truth-only), `RECONCILE-PER-CYCLE LOCK` |
| **F3** Entitlements | `db17452` | #7 grandfathering via F2 snapshot | `F3 ENTITLEMENT-TRANSMISSION-GATE LOCK` (entitlements ship in 2 places only) |
| **F10** Lifecycle | `75be9a6` | #15/#16/#17/#20 upgrade/downgrade/resubscribe/cancel | `GATEWAY-CHARGE LOCK` (manual gateway only for immediate upgrade v1) |
| **F11** Email automation | `5ca3ddc` | #22 advance notice, #23 cPanel SMTP | (dispatcher tx-threading rewrite) |

### Migrations added (in apply order)

| Migration | Feature | Notes |
|---|---|---|
| `0015_tax_invoices.sql` | F7 | 4 tables (tax_profiles, customer_tax_details, invoices, invoice_sequences); deferred FKs via NOT VALID + VALIDATE to F2's billing_periods |
| `0016_coupons.sql` | F8 | coupons + coupon_redemptions; seeds WELCOME20 |
| `0017_plan_gateway_ids.sql` | F8 | `plans.gateway_plan_ids` jsonb mapping |
| `0018_coupon_constraints.sql` | F8 | partial UNIQUE `(coupon_id, subscription_id) WHERE topup_id IS NULL` + CHECK fixed_amount currency alignment |
| `0019_cycle_closed_at.sql` | F4 | nullable `subscription_usage_cycles.closed_at` for triage |
| `0020_topups.sql` | F5 | topup_skus + topups + deferred FKs from coupon_redemptions + invoices; seeds 3 starter SKUs |
| `0021_widget_service_status.sql` | F6 | `widgets.service_status` enum + admin override + audit trail |
| `0022_topup_debited_per_cycle.sql` | F6 | `subscription_usage_cycles.topup_minutes_debited` (RECONCILE-PER-CYCLE LOCK) |
| `0023_subscription_change_events.sql` | F10 | append-only audit of every change-plan action with before+after snapshots |
| `0024_email_automation.sql` | F11 | email_templates + email_send_log (idempotency_key partial-UNIQUE for fire-once semantics) |

### Headline behaviours per feature

**F7 Tax (`0fee171`).** India GST (CGST 9% + SGST 9% intra-state, IGST 18% inter-state) generated in-house via GST-compliant PDFs; international invoices delegated to Paddle as MoR. JWT-gated customer routes for tax-details GET/PUT + invoices list + PDF download. Admin routes for tax-profile CRUD, GSTR-1 CSV export, cross-tenant PDF access. P0 fixes from swarm: PATH-TRAVERSAL CVE closed (`safeInvoiceFilename` + `realpath` containment), CSV FORMULA INJECTION closed (`csvEsc` prepends apostrophe to `= + - @` tab CR cells), `F7 SECURITY LOCK` on PUT tax-details (country pinned to subscription currency after first paid period), USD invoice total now correctly equals gateway-charged amount, `TaxDetailsCard` unconditionally mounted on `/billing` (closes India B2B input-tax-credit gap).

**F8 Checkout + coupons (`eac88a7`).** Single `/billing/checkout` page wires F1+F2+F7+F9. Server resolves currency from `tenant.customer_tax_details.country` (F1 SECURITY LOCK), validates+applies coupons from our DB (gateway-agnostic — one code works across Razorpay-INR + Paddle-USD), computes tax via F7, hands off to F9 adapter, then F9 webhook orchestrator inserts coupon redemption audit row after subscription+invoice exist. SPA CheckoutPage with debounced coupon preview, gateway-URL host allowlist before redirect, deep-link plumbing `/signup → /verify-email → /login → checkout` via localStorage 30-min TTL. Admin Coupons CRUD with soft-delete. **PADDLE DISCOUNT MISROUTE closed** (was sending our internal code into `discount_id` field expecting `dsc_xxx` UUID). **GATEWAY-CHARGE-FULL-PRICE BUG closed** (adapters were sending catalogue `gatewayPlanId` regardless of coupon → customer charged full price while redemption row logged phantom discount).

**F4 Replenishment cron (`3f88fa6`).** Hourly cron closes elapsed monthly cycles + rolls yearly mid-period cycles (Decision #8). Plus admin force-tick + customer "Minutes refresh on" surface with yellow `cycleStuck` banner. Multi-instance safety via `pg_try_advisory_xact_lock` threaded through `db.transaction(tx)`. Post-TX alert dispatch (rollback never ships false-positive alerts). `AIW_CRON_DISABLED` env opt-out for dev/CI. **`F4 ENTITLEMENT-NARROWING LOCK`** closed: cron NEVER narrows entitlement (prior code flipped `status=active→closed` BEFORE successor cycle existed → F6 quota-gate gap window of seconds-to-26h while gateway webhook landed). Now: monthly cycles stay active until F9 webhook closes-then-inserts in same TX; cron only observes (defensive close if orphan, 26h past-due alert if no successor, else no-op). Yearly mid-period rollovers DO close+insert in same TX because cron IS the successor source.

**F5 Top-ups (`4be9a1e`).** Minute packs stacking on monthly quota. 12-month per-pack expiry (Decision #13). FIFO consumption (Decision #9 — F6 hook ready). Gateway-mediated purchase through F8 checkout via `purchaseType` discriminator. Backend `topup.service`: `listSkus`, `getTopupBalance`, `createTopupForPurchase` (gateway-txn UNIQUE idempotency), `adminGrantTopup/Refund` (status-guard idempotency), `debitFifo` (F6 hook with FOR UPDATE SKIP LOCKED), `markExpiredFifo` (cron hook), `computeExpiresAt` (calendar-month math). Razorpay via Orders `/v1/payment_links`; Paddle via ad-hoc Transactions price. Daily expiry cron `CronLockKey.TOPUP_EXPIRY (0x5a2c_d4f7)` + post-TX alerts. SPA: TopupCard on `/billing` (currency waterfall sub→tax→geo + real `<button disabled>` when not subscribed or tax-details missing); TopupsPage at `/billing/topups` (mobile-responsive collapse + aria-live balance); CheckoutPage branches on purchaseType with forfeiture-explicit copy.

**F6 Quota gating (`90bd310`).** Two-gate enforcement. Gate 1 = `/api/widget/resolve` returns `service_status` for page-load UX (cacheable advisory; orb disables on non-active). Gate 2 = `/api/sessions` does authoritative per-call check + sets Retell `max_duration_sec` (≤ 30 min hard cap per Decision #14). Reads entitlement window from `subscription_usage_cycles` (CYCLE-SOURCE-OF-TRUTH LOCK from F2). Calls F5's `debitFifo` after every `call_ended`. Admin can force-override (escape hatch for billing disputes + accidentally-failed-webhook recoveries) — override always wins, audit-logged to `provider_webhook_events`. **`F6 SECURITY LOCK`**: `service_status` decision computed SERVER-SIDE from server-truth state ONLY. **`RECONCILE-PER-CYCLE LOCK`**: `already_debited` scoped to current cycle via `subscription_usage_cycles.topup_minutes_debited` (Arch P0 caught cross-cycle math bug that under-counted overflow whenever prior cycle ate topups).

**F3 Entitlements (`db17452`).** Every per-plan feature (behavioural triggers, signed visitor identity, lead-capture pill, remove-branding, multilingual, transcript-search, post-call-analysis, unlimited-widgets, priority-support) enforced server-side by FILTERING the `/api/widget/resolve` response — embed never receives config for unentitled features. Backend write-paths (trigger CRUD, visitor-identity toggle, widget create) return `403 ENTITLEMENT_REQUIRED` before any DB mutation. Reads active subscription's FROZEN `plan_snapshot.entitlements` (Decision #7) with live-plan fallback for legacy non-subscribed tenants. **`F3 ENTITLEMENT-TRANSMISSION-GATE LOCK`**: entitlements ship over HTTP in exactly TWO places — `/api/widget/resolve` (entitlements stripped, only entitled feature config survives) and `/api/account/subscription` (owner-only). F1 SECURITY LOCK strip on `/api/plans` preserved. Headline UX fix: stranded visitor-identity post-downgrade — widget with `visitorIdentityEnabled=true` renders `<PausedByPlan>` banner above `VisitorIdentityCard` so customer keeps disable-toggle access; backend permits DISABLE without entitlement (only ENABLE gated). No new migration.

**F10 Lifecycle (`75be9a6`).** Customer change-plan / cancel / resume / cancel-pending-change surface. Decisions: #15 mid-cycle UPGRADE (immediate, proration, old-cycle minutes forfeited, anchor RESETS); #16 mid-cycle DOWNGRADE (applies at next renewal; `pending_change` jsonb carries frozen targetSnapshot so admin edits don't move goalposts); #17 after-expiry resubscribe (fresh signup, top-ups carry over via 12-month lifetime); #20 cancel (`cancel_at_period_end`, no prorated refund v1). **`GATEWAY-CHARGE LOCK`**: razorpay/paddle subscribers see `501 GATEWAY_UPGRADE_NOT_IMPLEMENTED` for immediate upgrades (F9 has no `chargeStoredPaymentMethod` yet); `'manual'` gateway gets end-to-end immediate-upgrade. Scheduled downgrades + cancel + resume work for ALL gateways. Snapshot override at renewal so frozen terms survive admin edits. Gateway `cancelSubscription` called BEFORE local TX; 502 on adapter error; F9 webhook honors `cancelAtPeriodEnd` defensively. SPA: success banner branches on `?planChange=upgraded|downgrade_scheduled`; forfeited-minutes warning above proration table; pending-change banner shows target plan name + cycle. F4 cron excludes `cancelAtPeriodEnd=true` from past-due alerts. Rate limits: 30/min on preview, 6/min on execute / cancel / resume / cancel-pending.

**F11 Email automation (`5ca3ddc`).** Event-driven email lifecycle engine. Admin-editable templates per `event_key`; dispatcher cron sends queued mail every 2 min. 11 lifecycle events: subscription_started, renewal_succeeded, payment_failed, trial_ending, quota_threshold_80/100, topup_expiring (60d/14d/7d), plan_upgraded, downgrade_scheduled, invoice_issued, plan_changed (Decision #22 advance notice). `email.service.ts`: `renderTemplate` (variable allowlist + HTML escape + CRLF strip), `enqueue` (catch-23505 idempotency), `dispatchQueued` (claim-then-send-then-update). `lifecycle.ts`: 9 per-event wrappers (fire-and-forget, deterministic idempotency-keys, swallows errors). `plan-change-notifier`: per-subscriber price-increase detection, schedules email at `renewal_date - min_notice_days`. Cron `CronLockKey.EMAIL_DISPATCH (0x6b3d_e8a1)` 2-min cadence, F4 lock pattern with NEW intermediate `status='sending'` claim state (process-kill mid-send doesn't loop). Event-hook integrations wired into F9 (handleSubscriptionCharged → started/renewed/invoice; handleSubscriptionPaymentFailed → payment_failed), F10 (applyUpgrade → plan_upgraded; scheduleDowngrade → downgrade_scheduled), F5 (topup-expiry cron → topup_expiring), F6 (checkAndNotifyQuotaThreshold → 80/100), F1 (admin `PATCH /plans/:planId?notifySubscribers=true` → plan_changed). Headline arbiter fixes: dispatcher tx-threading rewrite (lock was held on different connection than SELECT/UPDATE — protected nothing); test-send recipient allowlist. **Decision #23**: cPanel SMTP via existing mailer plugin; adapter seam ready for future ESP swap (Resend / Postmark / SES).

### Verification + deploy

All 8 commits typecheck clean. All 10 migrations applied cleanly on DEV (2026-06-01, see "DEV deploy of 5ca3ddc" milestone in CLAUDE.md). PROD still on `c6520a4` — same payload PROD deploy is the next chunk.

### Open follow-ups (per-feature)

- **F7:** GSTR-3B (monthly) export beyond GSTR-1 (current); auto-fetch state from GSTIN; B2B vs B2C invoice template differentiation.
- **F8:** trial-mode coupons untested at runtime (DB shape supports them; route doesn't yet wire the `free_trial` branch).
- **F4:** cron tick observability dashboard (currently only structured logs).
- **F5:** mid-cycle SKU upgrade (e.g. customer who bought `basic` wants to swap to `extra_plus` — currently must redeem both).
- **F6:** widget-level "buy top-ups now" link inside the visitor-facing transcript message when `QUOTA_EXCEEDED` would let visitors self-resolve, but UX P3 deferred.
- **F3:** entitlement-required gate copy varies per-feature today; consolidating into a shared modal component would dedupe ~6 lines × 9 features.
- **F10:** SPA cancel-confirm dialog is a `window.confirm` for v1 — design system Modal is a future polish.
- **F11:** template variable autocomplete in admin editor (currently free-text; users must remember `{{ planName }}` etc.).

---

## Session: Feature 9 — Payment gateway adapters (2026-05-30, uncommitted)

Third feature in the 11-feature Pricing + Tax plan (`.claude/plans/snappy-leaping-willow.md`). Built on the same 6-chunk + 3-agent swarm + arbiter + apply-fixes pattern established by F1 + F2.

### What this feature ships

The **adapter layer + webhook plumbing** for payment gateways. Two adapters behind one `PaymentGateway` interface — Razorpay for INR rails, Paddle for USD (Merchant of Record, absorbs international tax compliance per Decision #12). Plus customer-facing payment-methods CRUD and an admin triage card. NOT a complete checkout flow — that's Feature 8's job.

**Six chunks (each `npm run typecheck` clean before moving on):**
1. **9a — migration `0013_payment_methods.sql`** + Drizzle schema. PCI-clean: only `last4` + `brand` + `expires_at` display fields; never the PAN. Partial unique index on `(tenant_id) WHERE is_default = true` prevents multi-default while allowing all-false. CHECK constraints on `gateway IN ('razorpay','paddle')` and `last4 ~ '^[0-9]{4}$'`. Composite UNIQUE on `(tenant_id, gateway, gateway_method_id)` for ON CONFLICT idempotency.
2. **9b — `PaymentGateway` interface + Razorpay + Paddle adapters** using native `fetch` + `node:crypto`. No SDK deps (matches OAuth pattern). Razorpay: HMAC-SHA256 hex header, `timingSafeEqual` length-checked, real Subscriptions/Cancel/Refund API calls. Paddle Billing v2: `ts=…;h1=…` header parser + 5-minute anti-replay tolerance, real Transactions/Subscriptions/Adjustments calls. Webhook event normalization to a single `InternalEvent` discriminated union. Typed errors: `GatewayNotConfiguredError` (→503), `WebhookSignatureError` (→401), `GatewayNotImplementedError` (→501) — all fail closed, never silently mock. `getGatewayForCurrency` router preserves F1's SECURITY LOCK.
3. **9c — webhook routes + `processInternalEvent` orchestrator.** `POST /api/webhooks/{razorpay,paddle}` PUBLIC routes (signature IS the auth). Dispatches to F2's `createSubscription` (new sub) or `createNextBillingPeriod` (renewal). Past_due recovery: when a renewal succeeds on a sub that was past_due, status flips back to active and `widgets.status = 'payment_failed'` rows lift to `'active'`. Unknown event types log + return success (no 500-retry storm).
4. **9d — payment-methods backend.** `/api/account/payment-methods` JWT-gated GET / POST add / DELETE / POST set-default. `resolveTenantCurrency` reads server-side from active subscription → `users.currency_preference` → default USD. NEVER trusts request input. Per-route rate limit on `/add` at 6/min (outbound API DoS guard).
5. **9e — SPA `/billing/payment-methods` page.** `PaymentMethodsPage.tsx`. Lists cards with brand + last4 + expiry + default badge. Mobile-responsive row layout. Add CTA gated behind `VITE_BILLING_ADD_CARD_ENABLED` (default OFF). 501/503/409 error codes mapped to distinct copy. Post-add list polling with 30s backstop.
6. **9f — admin Payment methods card.** Read-only ops triage. `GET /admin/tenants/:tenantId/payment-methods` endpoint. No delete/set-default from admin (single audit trail via customer SPA — impersonate the tenant if ops needs to force-change).

### Swarm review

Three parallel agents (Architecture + Security + Product/UX) reviewed the diff independently. Summary:

| Lens | Critical | High | Medium | Low |
|---|---|---|---|---|
| Architecture | 3 | 5 | 4 | 3 |
| Security | 1 | 4 | 3 | 2 |
| Product/UX | 2 | 4 | 4 | 2 |

**Cross-cutting findings (raised by multiple agents):**
- `upsertFromWebhook` silently demoted defaults on duplicate webhook delivery (Arch CRITICAL + Sec CRITICAL).
- `setDefault` two-update flip had a deadlock window under concurrency (Arch HIGH + Sec MEDIUM).
- Webhook routes shared global 60/min rate limit → legitimate redelivery storms would 429 (Arch HIGH + Sec HIGH on related concern).
- 500 → unbounded retry on poison-pill event types (Arch HIGH + Sec MEDIUM).

Lead arbiter consolidated to 22 prioritized FIX items + 2 DEFERs. All FIX items applied:

| Severity | Count | Examples |
|---|---|---|
| CRITICAL → FIX | 4 | Renewal idempotency via DB UNIQUE + 23505 catch; concurrency race resolved by same; upsertFromWebhook default-preservation; Add CTA gated behind feature flag |
| HIGH → FIX | 11 | setDefault single-statement rewrite; webhook event dedup via provider_webhook_events; rate-limit exemption + per-route /add limit; PADDLE_API_BASE allowlist; Paddle parseCents throws on non-integer; module-boundary doc; 501/503/409 distinction; mobile responsive; auto-refresh post-add; empty-state branching; past_due → /billing/payment-methods link |
| MEDIUM → FIX | 6 | Header-array normalisation both adapters; Razorpay silent-drop logging; getGatewayByName → unsafe_getGatewayByName rename; aria-label on default badge; remove-default 409 refusal |
| LOW → FIX | 6 | __keep_imports leftover removed; body-slice 240→80 chars; setSearchParams returns new URLSearchParams; copy nits; ack+log default case; TRIAGE log line for unknown plan_code |
| MEDIUM → DEFER | 2 | Button cluster reorg (ships with F10); INR-no-card edge case (resurfaces with F8) |

### Architectural decisions that landed in this PR

- **DB-level idempotency over app-level checks.** The original orchestrator had a SELECT-then-INSERT pattern for renewal idempotency that TOCTOU'd under concurrent webhook delivery. Replaced with `UNIQUE (subscription_id, period_start)` on `subscription_billing_periods` + 23505 catch on `createNextBillingPeriod` (mirrors F2's `createSubscription` 23505 pattern).
- **Webhook event dedup via existing `provider_webhook_events` table.** Partial unique on `(provider_name, provider_call_id) WHERE provider_name IN ('razorpay','paddle')` — Retell rows (which can share `provider_call_id` across events) are unaffected. Billing webhook routes INSERT before invoking the orchestrator; 23505 = dedup hit → 200 + skip.
- **Adapter feature gating via `VITE_BILLING_ADD_CARD_ENABLED`.** Until F8 lands and adapters' `createAddPaymentMethodLink` is verified end-to-end with live keys, the SPA Add CTA renders inert with explanatory copy. Avoids a shipping feature whose primary action is "always errors with 'this account type is unsupported' copy". Flip the flag to true ONLY after staging verifies the live-key roundtrip.
- **`unsafe_getGatewayByName` rename.** The dispatch-by-name function is exposed for webhook-route use where the gateway identity is already established by the verified signature. The `unsafe_` prefix is a code-review tripwire — future "let admin pick gateway" features can't pass review without explicit justification.
- **Native fetch + node:crypto, no SDK deps.** Both adapters are ~300 lines, use ~5 endpoints each. Razorpay SDK adds 30+ MB; Paddle SDK is similar weight. We get the same wire format without the bundle cost. Matches the existing OAuth-via-fetch pattern in `auth/oauth.service.ts`.

### Files added / modified

**New (5 files + 1 README):**
- `aiw-voice-backend/drizzle/0013_payment_methods.sql`
- `aiw-voice-backend/drizzle/0014_billing_idempotency.sql`
- `aiw-voice-backend/src/lib/db/schema/payment-methods.ts`
- `aiw-voice-backend/src/modules/billing/gateways/{gateway.interface,razorpay.adapter,paddle.adapter,index}.ts` + `README.md`
- `aiw-voice-backend/src/modules/billing/{billing.service,billing.routes,payment-methods.service,payment-methods.routes}.ts`
- `aiw-voice-app/src/api/payment-methods.ts`
- `aiw-voice-app/src/routes/PaymentMethodsPage.tsx`

**Modified:**
- `aiw-voice-backend/src/{app.ts, config/env.ts, lib/db/schema/index.ts, modules/admin/admin.routes.ts, modules/subscription/subscription.service.ts, admin/index.html, drizzle/meta/_journal.json}`
- `aiw-voice-app/src/routes/{AppRouter, BillingOverviewPage, PaymentMethodsPage}.tsx`
- `aiw-voice-app/.env.{development,staging,production}`
- Plan file: Feature 9 status block

### Deploy / verify

**NOT yet pushed.** Local `main` is now 3 commits ahead of `origin/main` after this commit lands (F1 + F2 + F9).

When the user asks to deploy:
```bash
git push origin main

ssh deploy@vps
cd /opt/aiw/dev && git pull && docker compose build && docker compose up -d
cd /opt/aiw/prod && git pull && docker compose build && docker compose up -d
```

Three migrations apply on the next compose up: `0011_plans_pricing_extension` + `0012_subscriptions` + `0013_payment_methods` + `0014_billing_idempotency` (0011-12 are from F1/F2; 13-14 are F9). All four are idempotent + reversible.

**Smoke tests after deploy:**
- `POST /api/webhooks/razorpay` with no signature → 401 (signature missing).
- `POST /api/webhooks/paddle` with no signature → 401.
- Authenticated `GET /api/account/payment-methods` → 200 with empty list.
- Authenticated `POST /api/account/payment-methods/add` → 503 (gateways not configured in dev/prod env yet) OR 501 (live-key roundtrip not wired) — depends on env state.
- SPA `/billing/payment-methods` renders; Add button is greyed out with "Coming soon" copy + mailto link (because `VITE_BILLING_ADD_CARD_ENABLED=false`).
- Admin Payment methods card renders empty (no methods saved yet).

### Where to next

Plan's implementation-order suggestion: **Feature 7 (tax module)** — needed before F8 (checkout) can issue invoices. OR **Feature 3 (entitlements gate)** — independent, cheap, uses data F1+F2 already shipped. User to choose.

---

## Session: Pricing module Features 1 + 2 (2026-05-30, committed `bda2e20` + `f56f398`)

This is the first installment of the 11-feature **Pricing + Tax** plan locked in `.claude/plans/snappy-leaping-willow.md` (23 decisions, mermaid flows, schema sketches per feature). Plan covers: dynamic plans + geo currency (F1), subscription model (F2), per-plan entitlements (F3), quota replenishment + payment dependency (F4), top-ups (F5), quota gating two-gate model (F6), India GST + International tax (F7), checkout + coupons (F8), payment gateway adapters (F9 — Razorpay INR + Paddle MoR USD), upgrade/downgrade lifecycle (F10), and email automation (F11). This session shipped F1 + F2 only.

### Working pattern established this session

Every feature in the plan follows the same 6-chunk implementation rhythm + swarm code review pattern. Worth codifying as the going-forward convention for the remaining 9 features:

1. **Chunks (a)–(f)** — one per surface touched: (a) backend schema/migration, (b) backend service/route, (c) SPA helpers/types, (d) public-facing surface swap, (e) dashboard surface, (f) admin surface. Each chunk is `npm run typecheck` clean before moving on.
2. **Swarm review** — 3 parallel `Agent` calls (Architecture + Security + Product/UX) reviewing the full diff. Each writes a structured finding list (Critical / High / Medium / Low) without seeing the others' output.
3. **Debate round** — agents see each other's findings and either concur, refute, or escalate.
4. **Lead arbiter** — separate agent reads all three reports + debate transcript, produces a prioritized fix list. Arbiter has final say on disputes.
5. **Apply fixes** — implement arbiter's list, re-run typecheck, commit with `HEREDOC` message + `Co-Authored-By` footer + explicit file-by-file `git add` (never `-A`).

23 fixes applied to F1, 12 to F2. Pattern produces ship-quality code in one round (no follow-up patch commits required for either feature).

### Feature 1 — Dynamic geo-localized pricing display (`bda2e20`)

**Outcome:** all pricing surfaces (homepage section, `/pricing`, dashboard `/billing/plans`) render from one DB source. Visitors see one price in their local currency (INR for India IPs via offline `geoip-lite`, USD elsewhere) with a manual `₹ / $` switcher. Admin edits any plan's name, prices, inclusions, and features list; changes appear on every surface on the next request.

**Schema (`0011_plans_pricing_extension.sql`):** extends `plans` with `price_inr_monthly_cents`, `price_usd_monthly_cents`, `price_inr_yearly_cents`, `price_usd_yearly_cents`, `tagline`, `features` jsonb, `inclusions` jsonb, `entitlements` jsonb, `display_order`, `is_public`, `highlight`, `updated_at`. Partial index on `(is_public, display_order)`. Adds `users.currency_preference` with `CHECK ('INR'|'USD'|null)`. Relabels seeded `basic`/`intermediate`/`advanced` rows → Starter/Business/Advanced with locked v1 pricing. Idempotent (`IF NOT EXISTS` on every ADD COLUMN) so re-running is safe.

**Backend:**
- `src/plugins/geo.ts` — `resolveGeoForRequest(req)` helper using offline `geoip-lite`. **Lazy** (called only inside `/api/plans`) per arbiter recommendation — making it a global hook would add per-request cost to unrelated endpoints.
- `Fastify({ trustProxy: true })` in `src/app.ts` so geo resolution works behind cPanel nginx → Docker (otherwise every request looks like it's from `127.0.0.1`).
- `GET /api/plans` — public, rate-limited 60/min per IP, `Cache-Control: private, max-age=60`. Returns both currencies + `features` + `inclusions` + `displayOrder` + `highlight`, with `detectedCountry`/`defaultCurrency` echo so the SPA can decide-once on first paint.
- **`entitlements` STRIPPED from public DTO** (post-swarm-review Sec H1 fix). The full snapshot stays in DB JSONB for Feature 6's quota gate; `reshapeForPublic(plan)` strips the key before serializing. `reshapeForAdmin(plan)` keeps it. Customer-facing SPA never sees gate keys (would let a customer probe entitlements client-side and engineer around them).
- Admin `/admin/plans` GET (list with full entitlements) + PATCH `/admin/plans/:id`. UUID validation. Narrowed single-highlight transaction (only flips other rows when `highlight` changed to `true`). Reply sent OUTSIDE the tx so post-commit failures cannot ship phantom 200s.

**SPA:**
- `<PricingGrid>` — single component consumed by homepage Pricing, `/pricing`, AND dashboard `/billing/plans` (with "Current plan" pill + legacy-plan callout + Upgrade vs Downgrade CTA branching via `me.planDisplayOrder`).
- **First-paint skeleton** until currency is known prevents the `$` → `₹` flicker that no-cookie India IPs would have seen (was a Product/UX swarm finding).
- Single React Query key (currency switch is purely client-side — both currencies already in the payload, no refetch). Retry button on error, explicit empty-state message.
- `<CurrencySwitcher>` + `lib/format-price.ts` (en-IN Indian comma grouping `1,24,788`; en-US for USD) + `lib/currency-storage.ts` (1-year `SameSite=Lax` cookie, `Secure` over HTTPS).

**Admin panel:** Plans button in topbar opens a modal with list + inline-edit form, live JSON validation (red/green border) and a transient "Saved" pill on successful save.

**Deferred (NOT in v1):** cross-device currency persistence. `users.currency_preference` column exists with the CHECK constraint, but no endpoint reads/writes it — Feature 10's `PATCH /api/account` work re-introduces the field end-to-end. The `Me.currencyPreference` API response field was deliberately removed pre-arbiter to avoid shipping a typed claim with no validation path.

**Plan-file deltas:**
- Feature 9 `SECURITY LOCK` callout: the checkout flow MUST resolve charge currency **server-side** from the authenticated tenant's country/tax profile. It must NEVER trust `?currency=` / `?country=` query params, `aih_currency` cookie, or any other client-controllable input at money-bearing endpoints. Feature 1's currency surface is display-only; the checkout-create handler is the trust boundary. Skipping this would turn Feature 1's display flexibility into a billing-fraud surface.
- GDPR escalation: `/privacy-policy` SPA route — already a Google OAuth verification follow-up — is now ALSO a Feature 1 EU-launch prereq. The geo IP → country pipeline processes personal data under GDPR Art. 4(1). Route must disclose: (a) IP-based geo resolution via offline MaxMind DB, (b) the `aih_currency` cookie's purpose + 1-year TTL, (c) that no IP is logged/stored beyond the request lifecycle. Same effort, two unblockers.

### Feature 2 — Subscription model + period-snapshot grandfathering (`f56f398`)

**Outcome:** foundation data model for paid plans. Read-only by design — write endpoints (cancel / change-plan / resume) deferred to Feature 10. This feature exists so Features 9 (gateways) and 10 (lifecycle) have something concrete to write against.

**Three tables, three concepts (`0012_subscriptions.sql`):**
1. **`subscriptions`** — one active subscription per tenant. Carries the LIVE `plan_id` reference (changes on plan switch). Partial unique index on `(tenant_id) WHERE status IN ('active','trialing','past_due','paused')` so canceled subs don't block a re-subscription (Decision #17 "after-expiry resubscribe = fresh signup").
2. **`subscription_billing_periods`** — one row per PAID period (monthly or yearly). `plan_snapshot` is the **FROZEN** jsonb plan row at period creation. Decision #7 (period-snapshot grandfathering): admin edits NEVER reach an active subscriber's current period. At their next renewal, the new live plan applies and a fresh snapshot is locked.
3. **`subscription_usage_cycles`** — monthly quota windows. 1:1 with billing_periods for monthly subs; 12-per-period for yearly subs (Decision #8 — minutes refresh monthly regardless of billing cadence). Feature 6's quota gate will read `minutes_included` as the per-cycle entitlement.

**Circular FK resolution:** `subscriptions.current_billing_period_id` ↔ `subscription_billing_periods.subscription_id` is a chicken-and-egg at INSERT time. Resolved by making the column nullable + adding the FK constraint AFTER both tables exist via `DO $ ALTER TABLE subscriptions ADD CONSTRAINT ... $`. FK is `ON DELETE SET NULL` so canceled subs don't break billing-period reads.

**Index direction:** schema indexes use `DESC` to match the SQL exactly. The Drizzle side uses `sql\`${t.periodStart} DESC\`` fragments (the `.desc()` modifier API moved across drizzle versions). If the schema and SQL drift on direction, `npm run db:generate` diffs and emits a `DROP INDEX ... CREATE INDEX` — bad in PROD where it briefly drops a hot query path.

**Service layer (`src/modules/subscription/subscription.service.ts`):**
- Public reads: `getCurrentSubscription(tenantId)`, `getSubscriptionHistory(tenantId, periods=12)`.
- Internal (`@internal` JSDoc-tagged so callers must come from gateway webhooks / change-plan flows once F9/F10 land): `takePlanSnapshot(planId)`, `createSubscription({...})`, `createNextBillingPeriod({...})`.
- **Split DTOs:** `PlanSnapshot` (internal, includes `entitlements`) vs `PlanSnapshotPublic` (no entitlements) via `toPublicSnapshot(snapshot)` boundary helper. Same Sec H1 reasoning as Feature 1 — gate keys never leave the server.
- **Cycle-window fallback:** if `getCurrentSubscription` finds a sub with no `currentUsageCycle` (transient state during a renewal-cron lag), it computes a `[periodStart, periodStart+30d)` window from the billing period itself instead of returning null. Customer-facing surfaces never see a "no cycle" gap.
- **23505 idempotency catch:** `createSubscription` catches the partial-unique-index violation thrown when two concurrent re-subscribes race; second caller gets the existing active row, not an error.

**Routes:**
- `GET /api/account/subscription` — current sub + period + cycle (JWT-gated).
- `GET /api/account/subscription/history?periods=N` — past billing periods (default 12, max 60).
- `GET /admin/tenants/:tenantId/subscription` — admin-key gated, UUID-validated. Mirrors customer endpoint for ops triaging.

**Seed updated:** `src/scripts/seed.ts` now creates a `subscriptions` row + first billing period + first usage cycle for the dev tenant so `/billing` has data on first boot.

**SPA `/billing`** — new `BillingOverviewPage.tsx`. Five arbiter UX fixes baked in:
1. **past_due / paused recovery banners** — explicit "Update payment method" or "Contact support" CTAs (not just a status badge customers can't act on).
2. **Trial countdown** — reads `subscription.trialEndsAt`, shows "Trial ends in N days" pill when `status === 'trialing'`.
3. **`pendingChange` disclosure banner** — when a downgrade is queued (Feature 10 will write this), customer sees "Switching to Starter on Jun 1 — [Cancel]" instead of a silent backend state.
4. **Yearly format parity with PricingGrid** — same `/mo billed yearly` copy + same Indian comma grouping.
5. **mailto cancel link** — v1 doesn't have a cancel endpoint (Feature 10 owns that). Instead of leading customers into a dead UI, the "Cancel subscription" CTA opens a `mailto:` to support. Removed when Feature 10 lands.

**SPA routing:**
- `/billing/plans` now hosts the plan-comparison surface (renamed from `/account/plans` to keep all billing-related URLs under one prefix).
- Legacy `/account/plans` becomes `<Navigate to="/billing/plans" replace />` so bookmarks keep working AND the sidebar highlight stays consistent. Arbiter fix #10 — replaces a duplicate-mount Route that didn't highlight the nav.
- New "Billing" sidebar item with `CreditCard` icon, `matchPrefix: '/billing'`, between Usage and the wizard entry.

**Admin panel:** new "Subscription" card in the widget inspector at `aiw-voice-backend/admin/index.html`. Shows `plan_snapshot.name`, status badge, period dates, current cycle minutes, `pendingChange` (raw JSON for ops triaging), expandable billing history table. Loads on widget select alongside existing Usage + Reconciliation cards.

**Reciprocal cycle-ownership note** added at the top of `src/lib/usage/periods.ts`: for subscribed tenants going forward, `subscription_usage_cycles` is the source of truth for the entitlement window. `periods.ts:currentPeriod` stays in service only for the `minutes_used` aggregate cache (correct by construction since it's just `SUM(conversations.duration_sec/60)`) and for legacy non-subscribed tenants. **Feature 6's quota gate MUST read the entitlement window (`cycle_start`, `cycle_end`, `minutes_included`) from `subscription_usage_cycles` for subscribed tenants — NOT from `currentPeriod()` below — or the two systems WILL drift after the first renewal that lands on a different calendar day.** Plan file's Feature 6 section carries the matching `CYCLE-SOURCE-OF-TRUTH LOCK` callout.

**Open follow-ups (deferred by arbiter, tracked for later):**
- Denorm `tenant_id` onto `subscription_billing_periods` + index `(tenant_id, period_start DESC)` so `getSubscriptionHistory` is a single index range scan instead of an `inArray` fan-out. Defer until a tenant accumulates >10 subscriptions.
- Zod re-parse `plan_snapshot` jsonb on every read (defense-in-depth for the future Feature 7 PDF invoice generator that won't auto-escape via React).
- Mobile-responsive billing history table (`@media` stack layout).

### Deploy / verify

**NOT yet deployed.** Both commits are on local `main` only — 2 commits ahead of `origin/main`. Per convention, push only when user explicitly requests it.

When the user does ask to push + deploy:
```bash
# from a dev box
git push origin main

# on the VPS for both envs
ssh deploy@vps
cd /opt/aiw/dev && git pull && docker compose build && docker compose up -d
cd /opt/aiw/prod && git pull && docker compose build && docker compose up -d
```

Two migrations apply on the next compose up: `0011_plans_pricing_extension` + `0012_subscriptions`. Both are idempotent and reversible. Smoke tests after deploy:
- `GET https://api.<domain>/api/plans` — returns plan list with `detectedCountry` + `defaultCurrency` headers/body fields. Entitlements **NOT** in response.
- `GET https://app.<domain>/pricing` — renders price cards with the detected currency, switcher works.
- Log in as dev seed (`dev@example.com` / `devpass123`), hit `https://app.<domain>/billing` — shows the seeded Starter monthly subscription + current cycle minutes.
- `https://api.<domain>/admin/` → pick the seed widget → Subscription card loads with plan snapshot + history.

### Where to next

Plan file's implementation-order suggestion: **Feature 9 (payment gateway adapters)** is the natural next step — F1+F2 give us a data model with nowhere to actually take money. F3 (entitlements) is independent and cheaper to ship if a quick win is preferred. F11 (email automation) can run in parallel with F4–F10. User to choose.

---

## Session: Usage minutes tracking + Retell reconciliation (2026-05-22, uncommitted)

This is the **foundation for the pricing module**. Goal: every customer can see how many call minutes they've used in the current 30-day cycle (both account-level and per-widget), with full history; every admin can verify those numbers against Retell's authoritative records and fix drift one click at a time. The pricing module next phase will add quota caps, deactivation, and top-ups — but it only reads numbers this layer produces.

### What changed and why

The customer dashboard had a "Conversation minutes" stat card, but no way to see usage per widget, no history beyond "all-time", and no concept of a billing cycle. The new feature adds:

- A 30-day **anniversary billing cycle** anchored on `tenants.subscription_started_at` (backfilled to `created_at` for every existing tenant). A signup on the 14th gets a 14th-to-14th cycle, not a 1st-of-month one. Decoupled from civil calendar.
- A **cached-aggregate compute model** that scales: reads always hit `usage_period_cache` (O(1)), and rows regenerate lazily from a single deterministic SQL aggregate over `conversations` when stale. The Retell webhook handler is **not modified** — it only writes `conversations`, and the next stale-read recomputes the affected period. This eliminates drift surfaces by design: the cache is always reproducible from the source.
- A **read-only Retell diff + manual reconciliation** flow for the admin panel. Admin clicks "Run diff" → sees rows present in Retell but missing locally (or with mismatched durations) → clicks "Backfill" or "Update" per-row (or "Backfill all" / "Update all"). Every action logs to `provider_webhook_events` as `admin_reconcile_backfill` / `admin_reconcile_update` with before/after values and triggers a cache recompute so the customer dashboard reflects the fix within seconds.

### Architecture decisions

| # | Decision | Choice |
|---|---|---|
| 1 | Billing anchor | New `tenants.subscription_started_at` (`timestamptz NOT NULL DEFAULT now()`, backfilled to `created_at`). Period math derives current cycle from how many full 30-day spans elapsed since the anchor. |
| 2 | Compute model | Cached aggregates with lazy recompute. `usage_period_cache` is a CACHE, not a ledger. Reads always hit the cache; staleness rule triggers a single deterministic SQL aggregate over `conversations` (CEIL(duration_sec/60) per row, then SUM). No incremental writes from webhooks → no drift detection needed → no ongoing reconciliation effort. |
| 3 | Cache staleness rule | Open periods refresh if `recomputed_at < now - 60s` OR on cache miss OR on explicit "Sync now". Closed periods (period_end + 1h grace < now) cached forever. |
| 4 | Retell reconciliation | Read-only diff endpoint; admin must click explicit per-row "Backfill" / "Update" actions for fixes. No auto-backfill (keeps a human in the loop the first few times we see drift; Retell list-API quirks could otherwise silently corrupt data). |
| 5 | Historical UI | New `/usage` SPA route. Period selector (This / Last cycle), headline cards, pure-SVG daily chart (~80 lines, no chart library), per-widget breakdown table. Dashboard + WidgetDetailPage get glanceable cards linking here. |
| 6 | New table or repurpose existing | Created new `usage_period_cache` (purpose-built for anniversary cycles + multi-scope keying). Old `usage_counters` (calendar-month YYYYMM PK, never populated) left untouched; will be dropped in a later cleanup once we confirm zero readers. |
| 7 | Admin-write cache invalidation | `recomputeAfterReconcile(widgetId, tenantId)` runs **unconditionally** at the end of every successful backfill or update-duration batch. NOT lazy — the customer dashboard reflects the fix immediately rather than waiting for the 60-second staleness window. (Lazy recompute is reserved for the read-path; write-path invalidations are explicit.) |
| 8 | Race-safety on duplicate inserts | Both the webhook handler AND the admin backfill insert into `conversations` with `ON CONFLICT DO NOTHING`. So a late webhook arriving in the middle of a manual backfill cannot collide — first writer wins, second writer is a no-op, and the next stale-read (or the post-batch `recomputeAfterReconcile`) catches the row up. |

### Files added

```
aiw-voice-backend/
  drizzle/0008_usage_tracking.sql
    ALTER tenants ADD subscription_started_at, backfilled to created_at.
    CREATE TABLE usage_period_cache (scope, scope_id, period_start) PK,
      period_end, minutes_used, call_count, recomputed_at + scope CHECK.
    Composite index on (scope, scope_id, period_start DESC).

  src/lib/db/schema/usage-period-cache.ts
    Drizzle table with extensive header comment explaining cache vs ledger
    semantics, the lazy-recompute pattern, and what TO add (more scopes,
    more metrics) vs NOT to add (incremental webhook writes).

  src/lib/usage/periods.ts
    Pure functions for the 30-day anniversary math:
      currentPeriod(anchor, asOf) -> { index, start, end }
      periodForDate(anchor, asOf)
      historicalPeriods(anchor, count, asOf) -> chronological array
      periodStartKey(period) / periodEndKey(period) -> 'YYYY-MM-DD'
      isPeriodClosed(period, asOf) — drives the staleness rule
    Half-open intervals [start, end). Truncates to UTC day for stable
    cache keys. No I/O, safe in hot paths.

  src/lib/usage/recompute.ts
    The ONLY writer to usage_period_cache by design.
      recomputeTenantPeriod(tenantId, period) -> { minutesUsed, callCount, recomputedAt }
      recomputeWidgetPeriod(widgetId, period) -> same
    Each is a single SQL: SUM(CEIL(duration_sec/60)) over conversations
    in [period_start, period_end), UPSERT into cache via
    ON CONFLICT (scope, scope_id, period_start) DO UPDATE.

  src/lib/usage/read.ts
    Staleness-aware reader.
      getTenantUsage(tenantId, period?) — applies the cache lifecycle rule
      getWidgetUsage(widgetId, period?) — same
      getTenantDailyUsage(tenantId, from, to) — uncached, GROUP BY day
      getTenantWidgetBreakdown(tenantId, periodStartKey) — per-widget rows
      recomputeAllWidgetsForTenant(tenantId) — "Sync now" fan-out

  src/modules/usage/usage.schemas.ts
    Zod for history (max 24 periods), daily (90-day cap), widgetIdParam.

  src/modules/usage/usage.service.ts
    Orchestrates the route handlers:
      getAccountCurrentUsage / getAccountUsageHistory
      getWidgetCurrentUsage / getWidgetUsageHistory
      getAccountDailyUsage / syncAccountUsageNow
      recomputeAfterReconcile(widgetId, tenantId) — exposed for the
        admin reconcile.service to invalidate after manual writes

  src/modules/usage/usage.routes.ts
    Fastify routes — all require JWT auth via requireAuth hook.
    GET  /api/usage/current
    GET  /api/usage/history?periods=12
    GET  /api/usage/daily?from=&to=
    POST /api/usage/recompute       (rate-limited 6/min)
    GET  /api/widgets/:widgetId/usage/current
    GET  /api/widgets/:widgetId/usage/history?periods=12
    All non-account-level routes call assertWidgetOwned (404 if the
    JWT's tenant doesn't own the widget).

  src/modules/admin/reconcile.service.ts
    runDiff(widgetId, from, to)
      -> { retellCount, localCount, matchCount,
           missingLocally, missingInRetell, durationMismatches }
    backfillCalls(widgetId, callIds, adminMarker)
      For each callId: fetch via provider.getCall, INSERT conversations
      (ON CONFLICT DO NOTHING for race-safety), audit-log to
      provider_webhook_events with event_name='admin_reconcile_backfill'.
      After batch, recomputeAfterReconcile (unconditional).
    updateDurations(widgetId, callIds, adminMarker)
      Same shape but UPDATE duration_sec to Retell's value, audit-log
      with event_name='admin_reconcile_update' (DISTINCT from the
      backfill event — dashboards filtering on event_name need both).
      Payload includes before/after duration_sec for each row.

  src/providers/retell/retell.usage.ts
    listRetellCalls({ agentId, fromMs, toMs }) — pages through
      client.call.list({ filter_criteria: { agent_id, start_timestamp:
      { lower_threshold, upper_threshold } }, pagination_key, limit: 1000,
      sort_order: 'ascending' }) until short page.
    Pagination loop is HARD-CAPPED at 100 iterations to bound blast
    radius if Retell ever returns a never-ending pagination key
    (max ~100k calls per diff window, which is way past any real tenant).
    getRetellCall(callId) — wraps client.call.retrieve, returns null on 404
    (the diff service uses null to mean "missing in Retell"); throws on
    other errors so the caller knows the fetch was incomplete and the
    diff should not be trusted as complete.
    Normalises both Web/Phone call responses into a single
    RetellCallRecord shape (callId, startTimestamp, endTimestamp,
    durationMs, recordingUrl, transcript, disconnectionReason).

aiw-voice-app/
  src/api/usage.ts
    Typed client wrapping all six usage endpoints + Retell-diff would
    live in admin/, not here (this is customer-facing only).

  src/components/UsageBar.tsx
    Reusable progress bar. cap=null renders flat (no quota set);
    >=80% leans amber, >=100% leans red. Ready for the pricing module —
    just pass `cap={plan.max_minutes_month}`.

  src/components/DailyMinutesChart.tsx
    Pure-SVG bar chart, ~80 lines. Fills in zero-call days locally
    from sparse backend response. Bars normalised to period max with
    native <title> tooltips on hover. Zero JS chart-library deps.

  src/components/UsageThisPeriodCard.tsx
    Dashboard card. Shows current-cycle minutes + calls + dates +
    UsageBar. Has "View usage" link to /usage. Reads
    /api/usage/current via React Query, refetches every 60s.

  src/components/WidgetUsagePanel.tsx
    Widget detail panel. Top: this-cycle minutes + calls + dates +
    UsageBar. Bottom: last-3-cycles mini-trail. Two queries via
    React Query (current + history). Stays under ~140px tall.

  src/routes/UsagePage.tsx
    Full /usage page. Period selector (This / Last cycle), three
    headline stat cards, daily chart, by-widget breakdown table.
    "Sync now" button hits POST /api/usage/recompute then invalidates
    FOUR React Query cache keys so every visible section re-fetches:
    'account-usage-current', 'account-usage-history',
    'account-usage-daily', 'widget-usage-current'. New usage views that
    need to participate in Sync-now must register their queries under
    matching prefixes. Custom-range picker deferred (the 90-day daily
    cap + 12-period history already cover the common cases).
```

### Files modified

| File | Change |
|---|---|
| `aiw-voice-backend/src/lib/db/schema/tenants.ts` | Added `subscriptionStartedAt: timestamp('subscription_started_at', { withTimezone: true }).notNull().defaultNow()` |
| `aiw-voice-backend/src/lib/db/schema/index.ts` | Added `export * from './usage-period-cache';` |
| `aiw-voice-backend/src/app.ts` | Imported + registered `usageRoutes` after `leadsRoutes` |
| `aiw-voice-backend/src/modules/admin/admin.routes.ts` | Imported `runDiff`/`backfillCalls`/`updateDurations` + period helpers; added 4 routes after mark-failed: `GET /widgets/:id/retell-diff`, `POST /widgets/:id/reconcile/backfill`, `POST /widgets/:id/reconcile/update-duration`, `GET /tenants/:id/usage` |
| `aiw-voice-backend/src/providers/types.ts` | Added optional `listCalls?(input)` + `getCall?(callId)` to `VoiceProvider` plus `ListProviderCallsInput` + `ProviderCallRecord` DTOs |
| `aiw-voice-backend/src/providers/retell/retell.provider.ts` | Implemented `listCalls` + `getCall` delegating to `retell.usage.ts`; added `toProviderRecord` helper that uses `Math.ceil(duration_ms / 1000)` (same rule as the webhook handler) |
| `aiw-voice-backend/drizzle/meta/_journal.json` | Added entry idx 8 for `0008_usage_tracking` |
| `aiw-voice-backend/admin/index.html` | Added "Usage" + "Reconciliation" cards to the inspector with `loadUsageCard`, `wireReconcileActions`, `runReconcileDiff`, `renderDiffTable`, `doReconcileAction` functions. The Reconciliation card has a date-range picker (defaults to last 30 days), Run-diff button, diff table with per-row Backfill / Update buttons, "Backfill all" / "Update all" bulk buttons; actions prompt for an audit-log marker, POST to the appropriate endpoint, auto-re-run the diff, then re-load the Usage card. |
| `aiw-voice-app/src/routes/DashboardPage.tsx` | Mounted `<UsageThisPeriodCard cap={null} />` in the StatCard grid |
| `aiw-voice-app/src/routes/WidgetDetailPage.tsx` | Mounted `<WidgetUsagePanel widgetId={widgetId} />` after `VisitorIdentityCard` |
| `aiw-voice-app/src/routes/AppRouter.tsx` | Imported `UsagePage`, mounted `/usage` under `<ProtectedRoute>` |
| `aiw-voice-app/src/components/ui/Sidebar.tsx` | Imported `Activity` lucide icon; added `{ to: '/usage', label: 'Usage', icon: Activity, matchPrefix: '/usage' }` to NAV between Widgets and Leads |

### Migration 0008 — what it does + deploy notes

```sql
ALTER TABLE tenants
  ADD COLUMN subscription_started_at TIMESTAMPTZ NOT NULL DEFAULT now();
UPDATE tenants SET subscription_started_at = created_at;

CREATE TABLE usage_period_cache (
  scope text NOT NULL,
  scope_id uuid NOT NULL,
  period_start date NOT NULL,
  period_end date NOT NULL,
  minutes_used integer NOT NULL DEFAULT 0,
  call_count integer NOT NULL DEFAULT 0,
  recomputed_at timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT usage_period_cache_pk PRIMARY KEY (scope, scope_id, period_start),
  CONSTRAINT usage_period_cache_scope_check CHECK (scope IN ('tenant','widget'))
);
CREATE INDEX usage_period_cache_scope_id_period_idx
  ON usage_period_cache (scope, scope_id, period_start DESC);
```

**Idempotency guarantees**: the `ALTER TABLE` is idempotent because `NOT NULL DEFAULT now()` populates new rows. The `UPDATE` is safe to re-run because it always sets the column to the immutable `created_at` value. `CREATE TABLE` is bare (no IF NOT EXISTS) — Drizzle won't re-run it because the journal tracks applied migrations.

**Deploy**: the `migrate` docker-compose service runs `node dist/lib/db/migrate.js` on `docker compose up -d`. No manual `npm run db:migrate` needed. Run order is enforced by the journal — `0008` lands after `0007` (Leads dashboard, also uncommitted on `main`).

### Verification steps used during this session

1. **Typecheck**: `npx tsc --noEmit` in `aiw-voice-backend/` and `aiw-voice-app/` — clean as of writing.
2. **Code commenting hygiene**: every new TS/JS file got a top-of-file rationale comment, every exported function got JSDoc covering purpose / inputs / outputs / side effects / failure modes. Non-trivial SQL fragments got `--` line comments explaining the CEIL rounding, the ON CONFLICT idempotency, and the half-open interval. Schema files got long-form headers explaining cache vs ledger semantics.

### Manual smoke tests to run after merge

1. **Migration**: `docker compose up -d` on `/opt/aiw/dev/` — verify `subscription_started_at` populated for all existing tenants; `usage_period_cache` exists and empty.
2. **First read populates cache**: `curl /api/usage/current` with a valid JWT → row appears in `usage_period_cache` with `recomputed_at = now`. Second call within 60s → no new row (HIT-fresh).
3. **Webhook → cache refresh**: place a real Retell call. `conversations.duration_sec` populated by `call_ended` webhook. Within 60s, `GET /api/usage/current` shows the new minutes (stale-read triggers recompute).
4. **Period boundary**: temporarily `UPDATE tenants SET subscription_started_at = now() - interval '29 days'` → current call counts toward this period; then `UPDATE … - interval '31 days'` → call moves to previous period in `GET /api/usage/history?periods=2`.
5. **Diff report**: place a call. `DELETE FROM conversations WHERE conversation_id = '<one>'`. Admin panel → Reconciliation → Run diff → call appears under "Missing locally".
6. **Manual backfill**: click "Backfill" → row reappears in `conversations`; audit row in `provider_webhook_events` with `event_name='admin_reconcile_backfill'`; Usage card numbers update.
7. **Manual duration update**: `UPDATE conversations SET duration_sec = duration_sec - 30 WHERE conversation_id = '<one>'` → diff shows duration mismatch → click Update → local matches Retell again; audit row with `before_duration_sec` + `after_duration_sec` in `payload`.
8. **Daily chart**: visit `/usage` → daily SVG bars render for current cycle; switch to "Last cycle" → chart re-renders with previous-cycle data.
9. **Sidebar nav**: "Usage" link appears between Widgets and Leads.

### Open follow-ups

- **Per-widget breakdown for past cycles**: the current `/api/usage/current` returns a per-widget breakdown for the CURRENT period only. The `/usage` page hides this section with a placeholder note when the user picks "Last cycle". A future backend endpoint `/api/usage/widget-breakdown?periodStart=` would unblock this.
- **Custom date ranges on /usage**: the period selector is locked to This / Last for v1. The daily endpoint already caps at 90 days, and `historicalPeriods` supports up to 24 periods, so custom-range UI is purely SPA work.
- **"Run diff across all widgets"**: today's diff is per-widget. For "did we lose webhooks across the fleet in this 4-hour window?" the admin would currently click through each widget. A bulk endpoint layering on `listRetellCalls` would scale that.
- **Closed-period per-widget recompute drift**: in extreme edge cases (admin reconciles a call that falls in an OLDER period), `recomputeAfterReconcile` only refreshes the CURRENT period cache. The old period's cache stays whatever it was. Plus the next stale-read won't trigger because closed periods are frozen. **Workaround**: a closed-period reconcile is rare; if it happens, manually `DELETE FROM usage_period_cache WHERE scope_id='<x>' AND period_start='<y>'` and the next read recomputes. Long-term fix: have `recomputeAfterReconcile` walk every period touched by the call_ids it just wrote.
- **Old `usage_counters` table**: untouched by this milestone. Once we confirm no code reads from it, a follow-up cleanup migration can drop it.

### Pricing-module setup (next phase — what's primed)

- **Quota cap source**: `plans.max_minutes_month` (already exists in schema as nullable integer). Pricing module reads it.
- **Quota join**: `tenants.plan_id` exists today as a `uuid` column (nullable, **not** FK-enforced — see `schema/tenants.ts:24` comment "hook for future pricing enforcement"). 0004's seed migration assigns every tenant to `basic`, so a left-join `tenants → plans` on `plan_id` returns a row for every tenant out of the box. Pricing module just needs the READER — the plumbing is there.
- **Quota usage source**: `usage_period_cache.minutes_used` for the current period (scope='tenant'). Single SELECT.
- **Gating point**: `/api/sessions` create handler. Add a check: if `(plan.max_minutes_month != null) AND (cache.minutes_used >= plan.max_minutes_month)`, return 402 with a friendly error code. Widget already has `RateLimitedError`-style handling — wire a `QuotaExceededError` next to it.
- **Widget deactivation surface**: `widgets.status='quota_exceeded'` returned by `/api/widget/resolve` so the orb knows not to mint a session. Existing `resolveWidget` query already left-joins `plans` — minimal change.
- **Top-up table**: new `usage_topups(tenant_id, period_start, minutes, granted_at, granted_by, source)`. Period quota = `plans.max_minutes_month + COALESCE(SUM(usage_topups.minutes WHERE period_start=current), 0)`.
- **UsageBar cap prop**: the SPA component already accepts a `cap` prop; pass the plan's quota in once the SPA can read it. `UsageThisPeriodCard` will then render the bar coloured (amber at 80%, red at 100%) automatically.
- **Alerts**: at 80% / 100% thresholds, the cache recompute path (or a separate scheduled job) can dispatch an email via the existing `mailerPlugin`. Not built yet.

---

## Session: Leads dashboard + `name` capture kind (2026-05-22, uncommitted)

Turns the widget's per-capture stream into a usable sales-CRM view. The Retell webhook + `session_captures` table were already writing one row per Submit on the widget's inline pill; nothing read those rows back. This milestone closes the loop: a tenant-scoped table that shows one row per call with `{ name, email, phone }` as columns, with a one-click jump to the call transcript.

### What changed and why

**Existing data flow** (already shipped in M2 PR 5):
- Widget's `inline-capture.ts` pill submits `(kind, value, source)` to `POST /api/sessions/:id/captures`.
- Backend inserts a row into `session_captures` keyed by `(widget_id, tenant_id, provider_call_id, kind, value, source, captured_at)`.

**What was missing:** no listing endpoint. Captures were dead-letter rows until someone queried the DB by hand.

**What was added:**
- **`kind = 'name'`** as a third capture type. The widget's pill already supports it; the DB CHECK constraint did not. Migration `0007` extends the constraint to `(email, phone, name)`.
- **`(tenant_id, captured_at DESC)` index** on `session_captures`. The new listing endpoint orders by `captured_at DESC` within a tenant; without an index, that's a sequential scan.
- **`GET /api/leads`** — JWT-auth-gated, tenant-scoped, with optional `?widgetId=`, `?from=`, `?to=` filters and `?cursor=&limit=` keyset pagination.
- **SPA `/leads` route** — global view across all the tenant's widgets.

### Architecture decisions

| # | Decision | Choice |
|---|---|---|
| 1 | Storage model | Roll up on read (no separate `leads` table). Keeps the data model simple; `session_captures` is the only writer. If listing perf gets slow at scale (>100k captures/tenant), the obvious next step is a materialised view, not a write-path change. |
| 2 | Aggregation strategy | `array_agg(value ORDER BY captured_at DESC)[1] FILTER (WHERE kind = '...')`. **Not** `MAX(value)` — that picks lexicographically and silently corrupts when a user corrects a typo. |
| 3 | Pagination | Keyset on `captured_at DESC` with `?cursor=<ISO datetime>`. Stable under concurrent inserts (unlike OFFSET). Index on `(tenant_id, captured_at DESC)` makes it an index range scan. |
| 4 | Filter on `provider_call_id IS NOT NULL` | Rows without a call_id can't be deep-linked and are exceedingly rare (the widget always has a Retell call_id by the time the pill is shown). Excluded from the rollup. |
| 5 | Conversation join | LEFT JOIN `conversations` on `provider_call_id`. Returns `conversationId: null` if the webhook hasn't landed yet — the SPA renders a disabled link in that case. |
| 6 | Conversation deep-link strategy | `?focus=:conversationId` against the existing `/widgets/:widgetId/conversations` page. That page auto-paginates up to 5 pages looking for the ID then scrolls + expands the row. Avoids building a separate detail route. |

### Files added

```
aiw-voice-backend/
  drizzle/0007_session_captures_name.sql
    DROP CONSTRAINT session_captures_kind_chk + re-ADD with 'name' allowed.
    CREATE INDEX session_captures_tenant_captured_idx
      ON session_captures (tenant_id, captured_at DESC).

  src/modules/leads/leads.routes.ts
    GET /api/leads (Zod-validated query). Maps ZodError → 400 with issues,
    AppError → its statusCode, anything else → opaque 500.

  src/modules/leads/leads.service.ts
    listLeads({ tenantId, widgetId?, from?, to?, cursor?, limit? })
      -> { leads: LeadDto[], nextCursor: string | null, total: number }
    Raw SQL using array_agg + FILTER. Joins widgets for widgetLabel and
    conversations for conversationId. Excludes provider_call_id IS NULL.

aiw-voice-app/
  src/api/leads.ts
    Typed client.
  src/routes/LeadsPage.tsx
    AppShell + Topbar. Widget dropdown filter (loads /api/widgets, 60s
    staleTime). Date range inputs (<input type="date">). useInfiniteQuery
    + IntersectionObserver sentinel. Each row links to
    /widgets/:widgetId/conversations?focus=:conversationId.
```

### Files modified

| File | Change |
|---|---|
| `aiw-voice-backend/src/lib/db/schema/session-captures.ts` | Updated header comment to mention `'name'` kind; CHECK constraint stays in SQL (Drizzle's pgEnum would be overkill for one swap). |
| `aiw-voice-backend/src/app.ts` | Imported + registered `leadsRoutes` (before `usageRoutes`). |
| `aiw-voice-backend/drizzle/meta/_journal.json` | Added entry idx 7 for `0007_session_captures_name`. |
| `aiw-voice-widget/src/utils/contact-intent.ts` | `detectAgentAsk` + `detectUserContact` already returned `'name' \| 'email' \| 'phone'` for M2 PR5; this milestone just makes the backend accept it. |
| `aiw-voice-widget/src/components/inline-capture.ts` | Same — pill already supported 3 kinds. |
| `aiw-voice-app/src/routes/AppRouter.tsx` | Imported `LeadsPage`, mounted `/leads` under `<ProtectedRoute>` between `/widgets` and `/usage`. |
| `aiw-voice-app/src/components/ui/Sidebar.tsx` | Added "Leads" nav item with `Users` lucide icon between Widgets and Usage. |

### Migration 0007 — what it does + deploy notes

```sql
ALTER TABLE "session_captures"
  DROP CONSTRAINT IF EXISTS "session_captures_kind_chk";
ALTER TABLE "session_captures"
  ADD CONSTRAINT "session_captures_kind_chk"
  CHECK ("kind" IN ('email', 'phone', 'name'));
CREATE INDEX IF NOT EXISTS "session_captures_tenant_captured_idx"
  ON "session_captures" ("tenant_id", "captured_at" DESC);
```

**Idempotency:** DROP CONSTRAINT IF EXISTS + CREATE INDEX IF NOT EXISTS — safe to re-run. ADD CONSTRAINT is not gated; Drizzle's journal handles the once-only contract.

**Deploy:** part of the same `docker compose up -d` that runs `0008` and `0009`. Run order is enforced by the journal: `0007` → `0008` → `0009`.

### Manual smoke tests to run after merge

1. **Migration**: verify `session_captures_kind_chk` allows `'name'` (try `INSERT … kind='name' …` directly in psql).
2. **Backend listing**: place a real call, fill in name + email + phone via the pill; `curl -H 'Authorization: Bearer <jwt>' /api/leads` → one row with all three fields.
3. **"Latest wins" rule**: place a call, capture `email=foo@a.com`, then via the pill correct to `bar@a.com`. `/api/leads` should return `bar@a.com`, not the lexicographically larger `foo@a.com`.
4. **Widget filter**: `?widgetId=` returns only matching rows.
5. **Date range**: `?from=2026-05-22&to=2026-05-22` returns today's only.
6. **Pagination**: `?limit=2` → response includes `nextCursor`; passing it back returns the next 2 rows without overlap.
7. **SPA `/leads`**: nav item appears in sidebar; table renders rows; clicking a row opens `/widgets/:id/conversations?focus=:cid` and the matching call expands.
8. **No-call edge**: an old capture row with `provider_call_id IS NULL` (synthesised) does NOT appear in the list.

### Open follow-ups

- **CSV export** of the Leads table — single `Download CSV` button on `/leads` calling a future `GET /api/leads.csv` (same filters). Common ask once a tenant accumulates >50 leads.
- **Materialised view** if listing slows past ~100k captures/tenant. Refreshed nightly + on-demand. Schema same shape as `LeadDto`.
- **Per-lead notes / status** — add a small `leads` ledger table keyed by `(tenant_id, provider_call_id)` carrying `status`, `assignee`, `notes`. Wires into the table as extra columns.

### Name capture — implementation precision (widget side)

A pass over `contact-intent.ts` / `inline-capture.ts` / `widget.ts` while writing the Leads endpoint surfaced details worth pinning down so future contributors don't drift them. None are new behaviour — they were established in M2 PR5 and survived `e8a4491` — but they were absent from the prose.

- **`detectAgentAsk` checks the name pattern FIRST**, ahead of email/phone, and only fires on agent utterances that end with `?`. The pattern set is exact: `full name`, `your name`, `first and last name`, `may i (have|get|know) your name`, `what(?:'s| is) your name`, `can i (have|get) your name`, `introduce yourself`, `who am i (speaking|talking) (to|with)`. Case-insensitive. The `?`-ending rule guards against statements like "I'll email you your full name later" tripping the detector.
- **`detectUserContact` deliberately does NOT return `kind='name'`**. Names are too ambiguous in free text to risk a false positive (and the consequence of misclassification is a lead row with the wrong name). Only `email` and `phone` are user-volunteered; `name` is agent-asked-only.
- **Pill input modes per kind** —
  - `email`: `<input type="email">`, placeholder `you@example.com`.
  - `phone`: country-code dropdown (`+1` / `+44` / `+91` / …) + `<input type="tel">`. Submitted as `"+CC NNNNNN"`.
  - `name`: `<input type="text">`, placeholder `Jane Smith`, `maxLength={120}` enforced client-side.
- **Pre-fill prompt UX**: when `visitorClaims.{name,email,phone}` is populated from a verified visitor JWT, the pill shows **"Use [value]?"** instead of an empty input. Submit classifies source as `pre_filled` (the third source-class beyond `agent_asked` and `user_volunteered`).
- **Duplicate-prompt guard**: a `capturedKinds` Set keyed on `kind` (per session) is checked before `show()`. The kind is added to the Set on EITHER dismiss OR submit, so the pill can fire at most once per `(kind, call)`. Repeated agent asks for the same kind don't re-surface it mid-call.
- **Capture rate limit**: `POST /api/sessions/:id/captures` is **60/hour per IP**, symmetric with the 30/hour `/api/sessions` limit (allows ~2 captures per session — name + email + phone overflow gets a 429 with a friendly transcript message).
- **Captures and the transcript reconciler are independent flows.** The 6-step `setEntries` pipeline (`dropInlineOrphans` → … → `dropSameRoleEchoes`) does NOT touch captures. When a capture is submitted, the widget surfaces it in the transcript via `transcript.appendInline(role, text)`, which sets the `sealed: true` flag — `sealed` entries are invisible to every reconciler pass and immune to Retell's per-tick shrinking arrays. Captures live in `session_captures`; transcript bubbles live in `retellEntries`; the two never share state.
- **Widget bundle size**: `dist/aiw-voice-widget.js` is **535 KB** after the `e8a4491` rebuild that landed name-kind. Was 523.6 KB at M2 PR5 lock-in, then 527.6 KB after the PR1 transcript-reconciler follow-up.
- **No widget-side commits between `44f15ac` (M2 lock-in) and HEAD other than `e8a4491`.** The widget surface has been stable for that window — useful baseline when reviewing what changed.
- **Trigger engine baseline (unchanged from M2)**: six condition types — `urlMatches` / `dwellSeconds` / `scrollDepth` / `exitIntent` / `idleSeconds` / `repeatVisitor`. Cooldown shape `{ perSession?, perDays? }`. JS API on `window.AIWWidget`: `.open(anchor)`, `.setVisitorToken(jwt)`, `.identify(token)`. No new condition types, cooldown options, or API methods since the M2 lock-in.

---

## Session: Master ops impersonation (2026-05-22, uncommitted)

Lets a named AI&Humn ops staff member sign into the customer SPA AS any customer for support, with a mandatory audit trail and a sticky in-app banner. Two-layer auth: the existing admin-panel `X-Admin-Key` gate **plus** the staff user's own email + password — so a leaked admin key alone cannot impersonate.

### Threat model and why two layers

The admin panel is gated by a single shared `X-Admin-Key` env var. Anyone with that key can call admin endpoints. If we let admin-key alone mint an impersonation token, then a leaked admin key would let an attacker sign in as any customer — and the audit log would have no real human to attribute the action to.

Layer 2 — requiring the staff user's own credentials — forces the actor to be a named human at the moment of impersonation. Failed Layer-2 attempts write **no audit row** (otherwise the table itself becomes an oracle for "which emails are staff?").

### What changed and why

**Existing surfaces:**
- `users` table — no concept of staff.
- `/api/admin/*` — gated by admin-key, no per-action attribution.
- JWT payload — `{ userId, tenantId }`; no impersonation marker.

**What was added:**
- `users.is_staff boolean DEFAULT false` — opt-in flag for ops staff. Regular signup/login/OAuth are unchanged for staff users.
- `impersonation_audit_logs` table — append-only record of every successful issuance.
- `POST /api/admin/impersonate` — the actual mint endpoint.
- JWT payload extended with optional `impersonatedBy: { staffUserId, staffEmail }`.
- `/api/auth/me` echoes `impersonatedBy` through verbatim (signed token; not forgeable).
- SPA `/auth/impersonate` landing — stores the token, redirects to `/dashboard`.
- SPA `<ImpersonationBanner />` — sticky yellow banner above `<Routes>`, only renders when `me.impersonatedBy` is set.
- `src/scripts/seed-staff.ts` — onboarding helper for ops users.

### Architecture decisions

| # | Decision | Choice |
|---|---|---|
| 1 | Auth model | Two layers — admin key + staff email/password. Neither alone is sufficient. |
| 2 | Staff onboarding | Existing user MUST sign up via `/signup` first (so they pick their own password + verify email). Then `seed-staff.ts` flips `is_staff=true`. No "create staff user" admin UI — avoids a path where an admin could conjure a staff account from the panel itself. |
| 3 | Failed-auth logging | Failed Layer-2 attempts write NO audit row. Otherwise the table becomes an oracle for "which emails belong to staff?". |
| 4 | Audit-row ordering | Audit row inserted BEFORE the JWT is signed. If the insert fails for any reason, no token is returned. An impersonation cannot happen without a log entry. |
| 5 | Denormalised email columns | `impersonation_audit_logs.staff_email` + `target_email` are duplicated from `users`. Even if the underlying user is deleted or re-keyed years later, the log row stays human-readable. The `*_user_id` FKs remain the authoritative identity. |
| 6 | Token shape | A real customer JWT with extra `impersonatedBy` claim. Downstream routes authorize by `userId`/`tenantId` as usual — no per-route impersonation awareness. The SPA reads `impersonatedBy` only to render the banner. |
| 7 | Landing route | New `/auth/impersonate` route (separate from `/auth/callback`). Same shape (`?token=` → login → redirect), but the URL surface makes the intent obvious in logs, browser history, and code review. |
| 8 | "Exit impersonation" | Just `logout()` + redirect to `/login`. The impersonation token was standalone; dropping it is enough. The ops user's original admin-panel tab (where they initiated the impersonation) is unaffected because we open the impersonation in a new tab. |

### Files added

```
aiw-voice-backend/
  drizzle/0009_impersonation.sql
    ALTER users ADD is_staff boolean NOT NULL DEFAULT false.
    CREATE PARTIAL INDEX users_is_staff_idx WHERE is_staff = true
      (nearly every row is false; only ops rows are looked up via the flag).
    CREATE TABLE impersonation_audit_logs (
      id, staff_user_id, staff_email, target_user_id, target_email,
      target_tenant_id, reason, ip_address, user_agent, created_at
    ).
    CREATE INDEX (staff_user_id, created_at DESC) and
                  (target_user_id, created_at DESC).

  src/lib/db/schema/impersonation-audit-logs.ts
    Drizzle table mirror with extensive header explaining append-only
    semantics and why both staff_email + staff_user_id are kept.

  src/modules/admin/impersonate.service.ts
    createImpersonationToken(app, request, { staffEmail, staffPassword,
      targetEmail, reason? }) -> { token, targetUserId, targetTenantId,
      redirectUrl }
    Order: verifyStaffCredentials -> lookup target -> insert audit row
    -> sign JWT.
    Errors: 401 INVALID_STAFF_CREDENTIALS (generic, no audit),
            404 TARGET_NOT_FOUND.

  src/scripts/seed-staff.ts
    `npx ts-node src/scripts/seed-staff.ts <email>`
    Idempotent. Exits 1 if email arg missing or no matching user.
    Warns if the user is unverified or OAuth-only (staff MUST have a
    real password — there's no way to verify password for OAuth-only).

aiw-voice-app/
  src/routes/ImpersonateLanding.tsx
    Mirrors OAuthCallbackPage. Reads ?token=, calls login(token),
    redirects to /dashboard. Guarded by useRef to prevent double-fire
    in Strict Mode.

  src/components/ImpersonationBanner.tsx
    Sticky yellow banner above <Routes>. Renders only when
    me?.impersonatedBy is set. "Exit impersonation" → logout() + nav to
    /login.
```

### Files modified

| File | Change |
|---|---|
| `aiw-voice-backend/src/lib/db/schema/users.ts` | Added `isStaff: boolean('is_staff').notNull().default(false)`. Header comment notes the impersonation-only effect. |
| `aiw-voice-backend/src/lib/db/schema/index.ts` | Added `export * from './impersonation-audit-logs';`. |
| `aiw-voice-backend/src/modules/auth/auth.service.ts` | New `verifyStaffCredentials(email, password)`. Strict: user exists + `is_staff=true` + `emailVerified=true` + valid argon2 password. Returns generic 401 for every failure mode (no enumeration). |
| `aiw-voice-backend/src/modules/auth/auth.routes.ts` | `/api/auth/me` now returns `{ ...me, impersonatedBy: req.user.impersonatedBy ?? null }`. |
| `aiw-voice-backend/src/modules/admin/admin.routes.ts` | Added `POST /admin/impersonate` route + `impersonateSchema` Zod body. Returns `{ token, targetUserId, targetTenantId, redirectUrl }`. |
| `aiw-voice-backend/src/plugins/jwt.ts` | JWT payload type extended with optional `impersonatedBy: { staffUserId, staffEmail }`. Header comment explains the claim's contract. |
| `aiw-voice-backend/drizzle/meta/_journal.json` | Added entry idx 9 for `0009_impersonation`. |
| `aiw-voice-app/src/routes/AppRouter.tsx` | Imported `ImpersonationBanner` (mounted above `<Routes>`) + `ImpersonateLanding` (mounted at `/auth/impersonate`). |
| `aiw-voice-app/src/api/auth.ts` | `me` response shape extended with optional `impersonatedBy`. |

### Migration 0009 — what it does + deploy notes

See `aiw-voice-backend/drizzle/0009_impersonation.sql` (read by the deploy runner). Idempotency: bare `ALTER TABLE ADD COLUMN` with `DEFAULT false` populates new rows; Drizzle's journal prevents re-run. `CREATE TABLE` is bare for the same reason.

### Onboarding the first ops user (Dhruv)

```bash
# 1. Sign up normally
open https://app.aiworkfllow.com/signup
# fill in dhruvdholakia@gmail.com + a strong password, verify email

# 2. Flip the staff bit (run on the VPS where DATABASE_URL is set)
cd /opt/aiw/dev
docker compose exec api npx ts-node src/scripts/seed-staff.ts dhruvdholakia@gmail.com
# → ✓ Flagged dhruvdholakia@gmail.com as is_staff=true.
```

For PROD: same flow against `https://aiandhumn.com/signup` + `/opt/aiw/prod/`.

### Manual smoke tests to run after merge

1. **Migration**: `docker compose up -d` → `users.is_staff` column exists with default false; `impersonation_audit_logs` table empty.
2. **Seed staff**: `seed-staff.ts <existing-email>` → flag flipped. Re-run → "already marked, no change".
3. **Reject non-staff actor**: `curl -X POST /api/admin/impersonate -H 'X-Admin-Key:...' -d '{"staffEmail":"customer@…","staffPassword":"...","targetEmail":"…"}'` → 401 INVALID_STAFF_CREDENTIALS. No audit row.
4. **Reject bad staff password**: same shape with wrong password → 401. No audit row.
5. **Reject missing target**: valid staff creds + bogus targetEmail → 404 TARGET_NOT_FOUND. No audit row.
6. **Happy path**: valid staff creds + real target → 200 with `{token, redirectUrl}`. Audit row appears in `impersonation_audit_logs` with `ip_address` + `user_agent` populated.
7. **Banner appears**: open `redirectUrl` → SPA lands on `/dashboard` with sticky yellow banner showing "You are impersonating <targetEmail>".
8. **Banner-less for normal logins**: log out, log in as a normal customer → banner does not render.
9. **Exit impersonation**: click "Exit impersonation" → redirected to `/login`; original admin tab unaffected.
10. **`/api/auth/me` echoes claim**: with the impersonation JWT, `me` returns `impersonatedBy: { staffUserId, staffEmail }`; with a normal JWT, returns `impersonatedBy: null`.

### Open follow-ups

- **Admin-panel UI for the impersonation form** — today the only consumers are direct curl + the admin-panel HTML form (manual). A dedicated card in `admin/index.html` with `staffEmail / staffPassword / targetEmail / reason` inputs + "Open as customer" button (which `window.open(redirectUrl)`) would close this.
- **Audit-log viewer** — `GET /api/admin/impersonation-audit-logs?staffEmail=&targetEmail=&from=&to=` for ops accountability. Append-only table is trivial to paginate.
- **Banner deep info** — currently shows `staffEmail` + "Exit". Could add "started X minutes ago" + "reason: ..." once the reason is round-tripped through the JWT.
- **Auto-expire impersonation tokens** — today they have the same TTL as normal JWTs. A future tightening would mint impersonation JWTs with a short `exp` (e.g. 30 min) so a stolen impersonation token decays fast.

---

## Session: About page + /how-it-works rewrite + WidgetFeatures section (2026-05-18 / 2026-05-19, committed)

Three intertwined commits that established the modular section-component pattern for non-homepage public routes. After this wave, every non-homepage public page is composed of named `<Page>Section` files importing from a typed `<page>.content.ts` and a per-page `styles/<page>.css`.

### Commit `e11332d` — Voice chat widget features section + /how-it-works card sync (2026-05-18)

**HomePage:** new `WidgetFeatures` 3×3 grid (`src/routes/landing/Sections.tsx`) between `Differentiation` and `UseCases`. Pure-CSS animated icons; honours `prefers-reduced-motion`. Uses `--font-display` (Urbanist) directly instead of the legacy `--font-sora` alias for a single source of truth.

**Coverage** (9 cards):
1. Live transcript
2. Behavioural triggers
3. Knowledge grounding
4. One widget vs per-page
5. Multilingual
6. 3× conversion
7. Script-tag install
8. Live in minutes
9. (final card varies — see `Sections.tsx`)

**`WidgetFeaturesFull`:** a separate export of the 9-card variant. Same chrome as `WidgetFeatures`; kept distinct so the homepage component can be tightened later without breaking the `/how-it-works` reuse.

**`/how-it-works` Steps section:** replaced with the homepage's animated step cards verbatim (drops the duplicate longer-copy variant + the unused legacy `STEPS` array). This is the first step toward the full rewrite that landed uncommitted on `main`.

### Commit `f827342` — Brand manifesto About page + frontend polish (2026-05-19)

**About page (`/about`):** full rewrite as a 10-section brand manifesto. Reference implementation for `aiw-voice-app/docs/page-design-principles.md`.

Section rhythm: *quiet → CLIMAX → quiet → personal → closing*. Section 2 is the climax — a dual-panel composition (black + cream) inside one shared rounded silhouette, with a custom inline-SVG line-art illustration (browser frame + live yellow orb + animated speech-curl).

**Framer Motion (new dependency)** used for:
- Word-stagger fade-up in the hero
- `whileInView` fade-ups on every section
- `staggerChildren` for list reveals
- `pathLength` draw-ins on SVG paths (illustration + YellowUnderline)
- `useScroll` + `useTransform` for Section 8's scroll-linked vertical timeline

`<MotionConfig reducedMotion="user">` wraps the page so every Framer animation honours `prefers-reduced-motion` in one line; CSS animations mirror this via `@media (prefers-reduced-motion)` in `about.css`.

**Recurring motifs:**
- `YellowUnderline` — same speed/easing/colour/thickness everywhere it appears (Sections 2, 4, 6 hover, 7, 8). Resist per-section variation.
- `AboutOrb` — decorative-only (CSS pulse + ring pulse + optional vertical drift). Not a button. Production widget Orb stays in the landing pages where clicks need to mint a voice session.

**Inline brand SVG icons** (`SocialIcons.tsx`) because `lucide-react` v1.11 dropped LinkedIn/Twitter/YouTube.

**Content lives in `about.content.ts`** — single typed source so future CMS migration is one swap.

**Polish fixes shipped in the same commit:**
- **`PublicLayout` reveal-class fix** — observer was calling `classList.add('revealed')` but `landing.css` defines the visible state under `.reveal.is-visible`. Every `.reveal*` element on `/how-it-works`, `/about`, `/pricing`, `/blog`, `/investors`, `/login`, `/signup` had been silently invisible since PublicLayout shipped. One-character fix; documented as a Key Convention in `CLAUDE.md` + the "Pitfall — Scroll-reveal class drift" section in this handoff.
- **`ScrollToTop`** — new component mounted inside `BrowserRouter`. Snaps to top on `PUSH`/`REPLACE` route changes via `useLayoutEffect` (no paint flash). Skips `POP` (preserves back-button restoration) and hash navigations (preserves anchor jumps).
- **HomePage orb buttons** — Hero, LiveDemo, UseCases all now call `window.AIWWidget.open()` when the marketing widget is loaded; else fall back to the in-app DemoModal. Logged-in users get the modal (MarketingWidget deliberately skips them); unauth visitors get the real voice chat positioned near the clicked orb.
- **HomePage refactor** — removed the local `HowItWorks` section, moved `WidgetFeatures` into its place.

### Uncommitted on `main` — `/how-it-works` full rewrite

`HowItWorksPage.tsx` is now a thin composer:

```tsx
<PublicLayout>
  <MotionConfig reducedMotion="user">
    <div className="hiw-page">
      <HiwHero />
      <HiwSteps />
      <HiwClimax />
      <HiwUnderTheHood />
      <WidgetFeaturesFull />   {/* reused from landing/Sections.tsx */}
      <HiwManifesto />
      <HiwKnowledge />
      <HiwClosing />
      <HiwFooterAdjacent />
    </div>
  </MotionConfig>
</PublicLayout>
```

**Section files** under `src/routes/how-it-works/`:

| File | Purpose |
|---|---|
| `HiwHero.tsx` | Eyebrow + headline + sub (from `hiw.content.ts`) |
| `HiwSteps.tsx` | Sign up → Knowledge → Embed (3 step cards; viz = `bars` / `docs` / `script`) |
| `HiwClimax.tsx` | "The one-tag promise" dark panel — syntax-highlighted `<script>` snippet + custom `illustrations/ClimaxIllustration.tsx`. CodeLines is a typed array; pills below it summarise the install promise. |
| `HiwUnderTheHood.tsx` | Animated cards explaining the runtime |
| `HiwManifesto.tsx` | Quiet brand-manifesto block |
| `HiwKnowledge.tsx` | KB pipeline explainer |
| `HiwClosing.tsx` + `HiwFooterAdjacent.tsx` | CTA + footer-adjacent quiet section |
| `illustrations/ClimaxIllustration.tsx` | Custom climax visual |
| `hiw.content.ts` | Typed single source of truth for every section's copy |
| `styles/hiw.css` | Per-page CSS; `var(--ink-2)` / `var(--yellow-300)` tokens defined here for the embedded `WidgetFeaturesFull` to resolve cleanly |

**Wireframe mock-ups** for the climax illustration are checked in at `aiw-voice-app/wireframes/climax-illustration-options{,-v2,-v3,-v4}.html` + `how-it-works-wireframe.html`. Useful when iterating on the dark panel.

### Public-page design north-star

The output of this wave is captured in **`aiw-voice-app/docs/page-design-principles.md`** — codifies the section rhythm, typography scale, colour discipline, motion language, and visual-climax rule. Read it before building any new public route (`/blog`, `/privacy-policy`, `/terms`, etc.).

The homepage (`/`) is intentionally **out of scope** — it follows its own landing-page conventions under `src/routes/landing/`.

### Files added (uncommitted /how-it-works rewrite)

```
aiw-voice-app/
  src/routes/how-it-works/HiwHero.tsx
  src/routes/how-it-works/HiwSteps.tsx
  src/routes/how-it-works/HiwClimax.tsx
  src/routes/how-it-works/HiwUnderTheHood.tsx
  src/routes/how-it-works/HiwManifesto.tsx
  src/routes/how-it-works/HiwKnowledge.tsx
  src/routes/how-it-works/HiwClosing.tsx
  src/routes/how-it-works/HiwFooterAdjacent.tsx
  src/routes/how-it-works/hiw.content.ts
  src/routes/how-it-works/illustrations/ClimaxIllustration.tsx
  src/routes/how-it-works/styles/hiw.css
  wireframes/climax-illustration-options.html
  wireframes/climax-illustration-options-v2.html
  wireframes/climax-illustration-options-v3.html
  wireframes/climax-illustration-options-v4.html
  wireframes/how-it-works-wireframe.html
```

### Verification

- All three commits typecheck clean.
- `/about` and `/how-it-works` paged through end-to-end in DEV; reveal animations all fire (the PublicLayout fix unblocked them).
- Framer Motion bundle delta acceptable; lighthouse perf score unchanged within noise.

### Open follow-ups

- **Pricing / Contact / Investors pages** should be re-rendered to the same section-composition pattern (today they're single-file React components).
- **`/blog`** stub — not yet built; would follow the same composer + `<blog>.content.ts` shape.
- **CMS migration** — once 3+ pages share the `<page>.content.ts` shape, a small typed CMS (or even a Markdown-frontmatter loader) becomes worth building so non-engineers can edit copy.

---

## Session: Typography refresh + wordmark logo deploy (2026-05-14)

Two-part visual refresh shipped together to DEV. Both changes are tokenized/centralised — every component reads font tokens from the design system, and the logo is one SVG asset referenced from three header surfaces.

### Part 1 — Font system swap

**Before:** `--font-display: 'Libre Baskerville'` (sharp transitional serif, 32px h1/h2/h3) + `--font-body: 'Inter'` (narrow neutral sans, 14px body) + `--font-mono: 'Geist Mono'`.

**Diagnosis:** Libre Baskerville's pointed terminals visually clashed with the new Monr-based wordmark (geometric + rounded). Inter at 14px also read as condensed next to the wider rounded shapes the brand needs.

**After:** `--font-display: 'Urbanist'` (geometric with fully-rounded terminals — Sora's silhouette but softer; matches Monr's letterform DNA) + `--font-body: 'DM Sans'` (humanist, ~5% wider than Inter at the same px) + `--font-mono: 'Geist Mono'` (unchanged — Geist Mono is geometric + roomy, already pairs).

**Why not full Option A (Sora):** Sora has clipped (not rounded) terminals. The Monr wordmark wants curves end-to-end. Urbanist is the same lineage but with proper rounded stem ends. User-confirmed pick.

**Minimal diff:** Two locations in `aiw-voice-app/src/design-system/colors_and_type.css` — the `@import url(...)` (line 13) and the three `--font-*` token values (lines 78–80). Original values **preserved as commented twins** in the same block so rollback is a 30-second find/replace, no git required. Font sizes / weights / line-heights deliberately untouched per user constraint.

**Free fix bonus:** `landing.css:1300` previously defined `--font-sora: "Sora", "Inter", system-ui, sans-serif` — a landing-page-local token referenced in ~10 places. With Sora and Inter both unloaded, it was silently cascading to system-ui. Re-aliased to `var(--font-display)` so the landing finally renders headers consistently with the rest of the app. Stale "Sora" comment on line 1287 updated to "display font alias".

**Cosmetic caveats NOT addressed:**
- `aiw-voice-app/src/design-system/assets/{logo,wordmark-dark,wordmark-aih-dark}.svg` still hardcode `font-family="Inter, system-ui, sans-serif"` inside `<text>` elements. SVG `<text>` ignores CSS variables. These are also superseded by the new wordmark — leaving them for a separate cleanup pass once we confirm nothing imports them.
- Local Libre Baskerville `@font-face` declarations (`colors_and_type.css:16–29`) left intact — zero cost when unreferenced.
- `aiw-voice-app/src/design-system/fonts/README.md` still documents Inter + Libre Baskerville. Stale, deferred.

### Part 2 — Wordmark logo wired into headers

User supplied `AIHumn-logo.svg` (Monr-based; orange `#d4900a` originally, recolored to `#feca00` and committed standalone as `e58a51a`). Three header surfaces previously rendered text marks at the brand position. Each got the SVG inserted at a height matching the prior text-mark vertical footprint, with `width: auto` so the SVG's natural 6.27:1 aspect ratio derives the width.

| Header | Previous content | New content | Logo height |
|---|---|---|---|
| Landing TopNav (`Atoms.tsx` `<Wordmark/>`) | `<h3>ai&humn</h3>` (32px display font) | `<img src={aihLogo}>` | **32px** |
| Dashboard sidebar (`Sidebar.tsx`) | 28px circle + "AIWoiz" 16px text | `<img src={aihLogo}>` | **24px** |
| Admin topbar (`admin/index.html`) | brand-mark circle + `<h1>AI&Humn Admin</h1>` | `<img src="...">` | **24px** |

**SVG imports** use Vite's built-in URL string handling (`/// <reference types="vite/client" />` already present in `vite-env.d.ts`). No new ambient declarations needed. Asset shipped in two locations:
- `aiw-voice-app/src/design-system/assets/aih-logo.svg` (bundled by Vite)
- `aiw-voice-backend/admin/design-system/assets/aih-logo.svg` (served as static file from the admin panel)

**Deliberately NOT swapped (out of "header nav" scope):**
- `.auth-card__brand` text inside `LoginPage.tsx` / `SignupPage.tsx` (3 instances) — sits inside the auth card body, not the page header. PublicLayout's TopNav above it already gets the SVG via Atoms.tsx.
- `footer__brand-name` in landing footer (`SectionsB.tsx:491`) — footer, not header.
- All inline marketing-copy mentions of "AI & Humn" in body text across About, Investors, Contact, landing Sections / SectionsB — those are prose, not brand marks.

### Files touched

```
aiw-voice-app/src/components/ui/Sidebar.tsx          | logo wiring
aiw-voice-app/src/design-system/colors_and_type.css  | font swap (with rollback comments)
aiw-voice-app/src/routes/landing/Atoms.tsx           | logo wiring
aiw-voice-app/src/routes/landing/styles/landing.css  | --font-sora alias + stale comment
aiw-voice-backend/admin/index.html                   | logo wiring
```

5 files, ~25 net lines. Typecheck clean.

### Deployment

Both commits pushed to `origin/main`; dev VPS (`/opt/aiw/dev/`) pulled and rebuilt:

```bash
ssh deploy@vps
cd /opt/aiw/dev
git pull
docker compose build app api    # both — app for fonts/logo, api for admin/index.html
docker compose up -d app api
```

Live URLs:
- `https://app.aiworkfllow.com/` — Urbanist headings, DM Sans body, orange wordmark in TopNav
- `https://app.aiworkfllow.com/dashboard` — wordmark in sidebar header
- `https://api.aiworkfllow.com/admin/` — wordmark in admin topbar

### Rollback paths (still valid)

1. **30-second swap in `colors_and_type.css`** — the original Inter + Libre Baskerville `@import` and `--font-*` triplet are preserved as commented twins right next to the active values. Uncomment one, comment the other, save.
2. **`git revert cca3cbf eb57683`** — reverts both commits cleanly. `e58a51a` (the asset) stays.
3. **Single-commit revert per concern** — `git revert eb57683` only undoes the logo wiring; `git revert cca3cbf` only undoes the font swap.

### Open follow-ups

- Auth-card brand text + footer brand-name still text. Swap if the user wants them logo'd too.
- Old wordmark SVGs in `design-system/assets/` (`logo.svg`, `wordmark-dark.svg`, `wordmark-aih-dark.svg`) likely unused now. Audit imports across the codebase; if nothing references them, delete.
- `fonts/README.md` documents the pre-Urbanist setup. Update or delete.
- PROD deploy still pending overall stack milestone (separate dependency: `aiandhumn.com` cPanel account creation).

---

## Session: M2 PR1 follow-up — transcript reconciler hardening (2026-05-13, uncommitted)

Live-call smoke testing of the M2 widget surfaced a string of transcript-display bugs all rooted in how `setEntries` reconciles Retell's per-tick transcript snapshot against our local `retellEntries` model. Retell's transcript array is not a clean append-only event log — during user interruption it emits truncated turns, tail-fragment echoes, ASR partials that never finalise, and cumulative-growth entries that span an interrupt boundary. Each one needed its own pass to keep the visible bubble list sensible.

`aiw-voice-widget/src/components/transcript.ts` grew from a thin reconciler into a 6-step pipeline. `aiw-voice-widget/src/widget.ts` got a small wiring change for one of them. Nothing else in the codebase moved.

### Pipeline (final order in `setEntries`)

```
incoming Retell snapshot
  │
  ▼ step 1a — dropInlineOrphans()
incoming, minus short opposite-role mid-orphans (ABA → AA)
  │
  ▼ step 1b — mergeConsecutiveSameRole()
incoming, with consecutive same-role chunks merged
  │
  ▼ step 1c — applySplitPrefixes()
incoming, with silence-breadcrumb prefix snapshots stripped
  │
  ▼ step 2 — main reconcile loop (findMatchFromEnd + superset guard)
retellEntries mutated in place
  │
  ▼ step 3 — collapseOrphans()
retellEntries, minus retroactively-discovered ABA mid-orphans
  │
  ▼ step 4 — dropSameRoleEchoes()
retellEntries, with prefix/suffix echo pairs deduped
```

`trimTrailingOrphans()` is a separate public method invoked on call-end (`onCallEnded` / `onError`) to evict any stale trailing ASR partial before "CALL ENDED" lands on screen.

### What each pass solves

1. **`dropInlineOrphans`** (incoming, pre-merge) — drops a short (≤ 12 chars, no sentence-final punct) opposite-role entry sandwiched between two same-role neighbours in the incoming snapshot. Lets `mergeConsecutiveSameRole` collapse the surrounders. Symptom without it: user's interrupted question "Where is" / agent fragment "Vitamin D" / user "is your" — three separate bubbles instead of one merged user turn.

2. **`mergeConsecutiveSameRole`** (incoming, unchanged from M2) — merges consecutive same-role entries by exact-dup drop, prefix-growth keep-longer, or fall-through string concatenation.

3. **`applySplitPrefixes`** (incoming, post-merge) — when the user has tapped "I'm still here." mid-call, the most recent in-flight retell entry was sealed with `splitPrefix = current content` at tap time. Any subsequent Retell-cumulative-growth that starts with that prefix has the prefix stripped here, so the **suffix** routes to a fresh post-breadcrumb bubble instead of swallowing back into the pre-breadcrumb bubble. Iteration is left-to-right through `retellEntries` to handle chained breadcrumbs (two taps → two stripped prefixes). Empty post-strip turns are dropped.

4. **Main reconcile loop** (unchanged structure; new `sealed`-skipping in `findMatchFromEnd`) — Pass 1+2 (exact / growth) skips sealed entries; Pass 3 (same-role-at-end fallback) bails out entirely if the last entry is sealed (chronological boundary — new content appends after the breadcrumb, never matches the in-flight turn that lives above it). Superset guard at the call site still protects against ASR rollback shrinkage.

5. **`collapseOrphans`** (`retellEntries`, post-reconcile) — an earlier update may have appended a tiny opposite-role entry as the last retell entry, before the third surrounding same-role turn arrived. Now that more entries exist, an ABA pattern emerges retroactively; this pass evicts mid only (does NOT merge prev+next contents — see comment in code about why concatenation would create retransmission duplicates).

6. **`dropSameRoleEchoes`** (`retellEntries`, post-collapse) — final pass. Walks `retellEntries` looking for same-role pairs (a, b) with at most ONE non-sealed entry between (i.e. adjacent OR separated by one user turn). If the contents match exactly, or one is a clean **prefix or suffix** chunk of the other, the shorter row is dropped from model + DOM. Catches:
   - "Vitamin D is important for both women" appearing back-to-back after `collapseOrphans` removed a short user partial between two agent entries.
   - Agent's "...assist" followed by a separate "assist" tail-echo entry from Retell (suffix containment).
   - Agent's "...Let me know" → user "Yeah. Can you" → agent "supplement? Let me know" — Retell re-emitting the truncated agent tail after the user interrupted (suffix containment with one user turn between).

### Sealed entries and `splitPrefix`

`retellEntries` entries now optionally carry `sealed: true` and `splitPrefix: string`. Set only by `appendInline()` (new public method on `TranscriptPanel`):

- `sealed: true` — this row is a local breadcrumb (currently just "I'm still here." from the silence-prompt button). Invisible to all match passes, never collapsed, never trimmed.
- `splitPrefix` — content snapshot of the *previous* in-flight retell entry at the moment the breadcrumb was inserted. Used by `applySplitPrefixes` to route post-breadcrumb growth to a new bubble.

`widget.ts` change: `dismissSilencePrompt(resetTimer=true)` now calls `this.transcript.appendInline('user', "I'm still here.")` instead of `this.transcript.append(...)`. The old `append()` used the tail zone (DOM-bottom), so all post-tap turns inserted *above* the breadcrumb. The new `appendInline()` anchors the breadcrumb at the chronological end of the retell zone.

### `widget.ts` call-end trim

`onCallEnded` and `onError` callbacks both now call `this.transcript.trimTrailingOrphans()` before the rest of their cleanup. Drops stuck ASR partials like an aborted "Is" the user said while trying to interrupt — without this, the bubble lingers as the visual last thing on screen with "CALL ENDED" above it, looking like the user's final utterance.

### Files changed (delta on top of M2 PR1)

| File | Change |
|---|---|
| `aiw-voice-widget/src/components/transcript.ts` | +`appendInline`; +`dropInlineOrphans`; +`collapseOrphans`; +`trimTrailingOrphans` (public); +`applySplitPrefixes`; +`dropSameRoleEchoes`; +`isOrphanContent` static; sealed-skipping in `findMatchFromEnd`; `retellEntries` element type extended with `sealed?` + `splitPrefix?` |
| `aiw-voice-widget/src/widget.ts` | `dismissSilencePrompt` → `appendInline`; `onCallEnded` + `onError` call `trimTrailingOrphans` |

Worktree (`.claude/worktrees/nice-nash-e4c8aa/aiw-voice-widget`) and main repo (`aiw-voice-widget`) are byte-identical. Both typecheck clean, both build to `dist/aiw-voice-widget.js` at 527.6 KB.

### Heuristic constants (tunable in `transcript.ts`)

- `MAX_ORPHAN_CHARS = 12` — orphan-classification ceiling.
- `SENTENCE_END = /[.!?]\s*$/` — orphan-exemption regex (real short turns like "Yes." / "What?" pass through).

### Verification

- Local bundle rebuilt: `npm run build` → 527.6 KB.
- Typecheck clean on both worktree and main.
- Live-call smoke (user testing): all five reported bug scenarios fixed in screenshots/video.

### Deploy notes

- No new env vars.
- No new migrations.
- Widget bundle replacement only — `dist/aiw-voice-widget.js` swaps in on next deploy of the widget container.
- Customers on the dev/prod CDN URLs (`widget.aiworkfllow.com` / `widget.aiandhumn.com`) pick up the fix transparently after re-deploy. **Hard-refresh required** to bypass browser-cached old bundle during local testing.

---

## Session: Widget polish + anti-abuse — M2 (2026-05-12, uncommitted)

Plan reference: `~/.claude/plans/Widget-Feature-implementation-v2.md`.

Five PRs of widget-facing work, sequenced. All code is in the working tree (worktree `.claude/worktrees/nice-nash-e4c8aa` and main repo are aligned). Nothing committed yet — when you commit, prefer five separate commits in PR order so the history is bisectable.

### PR 1 — Quick wins (no schema, no API)

Three small fixes against pain points seen in production:

- **Transcript scroll fix** — `aiw-voice-widget/src/components/transcript.ts`: scroll-pin check now runs *before* DOM mutation. Previously we measured `scrollHeight - scrollTop - clientHeight` after appending the new content, by which point the user was no longer in the "pinned" zone — so new lines hid at the bottom. Now we capture `wasPinned` first, then mutate, then conditionally scroll. Pure logic fix, no UX change for users who were scrolling history.
- **Transcript text smoothing** — same file. `bubble.textContent = content` was replacing the whole string on every Retell streaming tick (5-10×/sec for live transcripts), causing the entire bubble to re-layout each time and reading as visibly jerky. Replaced with an append-suffix path: when the new content extends the old, we only append the delta as a Text node. Existing characters stay glued in the DOM. Falls back to full replacement if the new content diverges (rare — agent revising an earlier turn).
- **Mic constraints** — `aiw-voice-widget/src/components/mic.ts`: explicit `{ noiseSuppression: true, echoCancellation: true, autoGainControl: true }` instead of bare `{ audio: true }`. Modern browsers default these on, but some embedded WebViews don't; explicit lock-in for cafe-noise robustness costs nothing.
- **`aiw-voice-backend/docs/retell-agent-tuning.md`** — new ops doc. Per-agent settings to apply in the Retell dashboard: `interruption_sensitivity: 0.3`, `enable_backchannel: false`, `end_call_after_silence_ms: 15000`. Settings live in docs (not code) because the backend doesn't programmatically configure Retell agents today.

### PR 2 — Plan-gated "Powered by ai&humn" footer

- **Migration `0004_seed_plans.sql`** — inserts three plan rows (`basic`, `intermediate`, `advanced`) into the existing `plans` table (created back in `0000_initial`) and assigns every existing tenant to `basic` via `UPDATE tenants SET plan_id = ... WHERE plan_id IS NULL`. Idempotent: `ON CONFLICT (code) DO NOTHING` plus the `IS NULL` guard.
- **Backend resolve query** — `aiw-voice-backend/src/modules/widget/widget.service.ts`: left-joined `plans` on `tenants.planId`; new field `show_powered_by` in the response, `true` when `plans.code === 'basic'` (falls back to `true` when the join produces null — safer to over-attribute than to silently white-label a misconfigured tenant).
- **Resolve schema** — `widget.schemas.ts` extended with `show_powered_by: boolean`.
- **Widget rendering** — `aiw-voice-widget/src/widget.ts`: new private field `footerEl` (an `<a>` element pointing at `https://aiandhumn.com` with `target="_blank"`), mounted in the modal body after the controls row. `applyFooterVisibility()` helper toggles display based on the resolved config; called from both `bootstrap()` (so the footer paints as soon as resolve completes, even before the user opens the modal) and `startCall()` (idempotent — covers the case where bootstrap was skipped).
- **CSS** — new `.aiw-footer` class in `WIDGET_CSS` (centered, small, muted, focus-visible-friendly link styling).

### PR 3 — Silence detection + soft prompt

- **`aiw-voice-widget/src/components/silence-prompt.ts`** (new) — presentation-only component. Shadow-DOM toast with title, countdown line, and a "I'm here" button. Caller drives timing via `show(seconds)`, `setCountdown(seconds)`, `hide()`.
- **Widget timer logic** — `widget.ts`:
  - New state: `lastUserTurnAt`, `lastUserTextLen`, `silenceTickerId`, `silencePromptShown`.
  - `startSilenceWatch()` called from `onCallStarted`; sets a 1-second `setInterval` that compares `Date.now() - lastUserTurnAt`.
  - At ≥8 s → show the toast with a 5-second countdown.
  - At ≥13 s → `voice.stop()`, append "No audio detected — ending the call." to the transcript.
  - `observeUserActivity(entries)` runs on every `onTranscriptUpdate`: detects user-text growth by total length, resets the timer, dismisses an active toast.
  - "I'm here" button → `dismissSilencePrompt(resetTimer=true)`.
  - `stopSilenceWatch()` called from `onCallEnded`, `onError`, modal close, end-call, restart-call.
- **CSS** — new `.aiw-silence-prompt*` classes.
- Hard backstop documented in PR 1's `retell-agent-tuning.md`: Retell agent's `end_call_after_silence_ms: 15000` hangs up 2s later if our client-side timer fails for any reason.

### PR 4 — Rate limit tightening

- **Backend** — `aiw-voice-backend/src/modules/sessions/sessions.routes.ts`: `/api/sessions` rate limit changed from `{ max: 30, timeWindow: '1 minute' }` to `{ max: 30, timeWindow: '1 hour' }`. Per-IP. Single-line change; the dominant abuse pattern is scripted token-drainage of Retell minutes, and an hourly cap is punishing for bots while staying well above any plausible human use.
- **Widget 429 handling** — `aiw-voice-widget/src/api.ts`: new `RateLimitedError` class thrown when `createSession` sees a 429. In `widget.ts`, catching this:
  - Stores `aiw_rate_limited_until:<widgetToken>` (absolute expiry timestamp) in `sessionStorage`.
  - Shows the rate-limit message in the transcript.
  - Disables the launcher orb for the remaining cool-down.
- **Reload safety** — `applyRateLimitCooldown()` runs in `init()`: reads the stored timestamp, re-disables the orb if still active, sets a `setTimeout` to re-enable when the cool-down expires.
- **Explicitly not built** (per user-locked decisions in the plan): concurrent-session check, `active_sessions` table, `client_id` plumbing, monthly per-tenant quota via `usage_counters`. All deliberately out of scope — the simplest knob (per-IP rate limit) is sufficient defense and avoids false-positive lockouts on NAT'd offices.

### PR 5 — Inline email/phone capture + signed-JWT visitor identity

Split into four sub-parts in the implementation but ship as one PR.

#### (a) Widget secret + toggle (opt-in per widget)

- **Migration `0005_visitor_identity.sql`** — `CREATE EXTENSION IF NOT EXISTS pgcrypto;` then adds two columns to `widgets`:
  - `visitor_identity_enabled boolean NOT NULL DEFAULT false`
  - `widget_secret text` (nullable)
- **Schema** — `aiw-voice-backend/src/lib/db/schema/widgets.ts` updated with both fields. The boolean defaults to false; existing widgets stay opted out.
- **Service** — `widgets.service.ts`:
  - `setVisitorIdentityEnabled(widgetId, tenantId, enabled)` — on first enable, generates a 64-char hex secret via `crypto.randomBytes(32).toString('hex')`. On disable, leaves the secret in the DB so re-enabling later doesn't break any tokens the customer's backend has already minted. Returns the secret only when the feature is on.
  - `rotateWidgetSecret(widgetId, tenantId)` — generates a fresh secret. Returns 409 `VISITOR_IDENTITY_DISABLED` if the feature is off, to surface misuse rather than silently creating a secret nothing checks against.
- **Routes** — `widgets.routes.ts`: three new auth-required endpoints:
  - `PATCH /api/widgets/:widgetId/visitor-identity` body `{ enabled: boolean }`
  - `POST /api/widgets/:widgetId/rotate-secret`
  - `POST /api/widgets/:widgetId/visitor-token/verify` body `{ token }` (debug endpoint powering the dashboard's "Test now" button — verifies a freshly-minted JWT against the widget's stored secret and returns decoded claims or a specific error code).

#### (b) JWT verifier + session-create path

- **`aiw-voice-backend/src/lib/jwt/visitor-token.ts`** (new) — single-purpose HS256 verifier built on Node's `crypto.createHmac` (no new dependency). Validates header `alg: HS256`, does timing-safe signature compare, checks `exp` (rejects past), rejects future-`iat` tokens with 60s skew tolerance, returns structured error (`INVALID_SIGNATURE | TOKEN_EXPIRED | MALFORMED`). Strips unknown claims — only `sub`, `email`, `phone`, `name`, `iat`, `exp` propagate.
- **`/api/sessions` flow** — `sessions.service.ts`: when `visitor_token` is present in the request body, three branches:
  - Widget has `visitor_identity_enabled=false` → silently ignore the token (don't error; the customer might embed the same widget across pages that vary).
  - Widget has feature on, token verifies → carry `verifiedClaims` through; include them in the response (`visitorClaims`) AND in the Retell `metadata` blob (`visitor_email`, `visitor_phone`, `visitor_name`, `visitor_external_id`).
  - Widget has feature on, token fails → 401 with the specific error code (`INVALID_VISITOR_TOKEN` or `TOKEN_EXPIRED`). Fail loud so customer-side integration bugs surface immediately.
- **Provider plumbing** — `providers/types.ts` got an optional `visitor` field on `CreateProviderSessionInput`. `retell.provider.ts` spreads `visitor_*` keys into the Retell metadata when present. The agent itself does NOT see these (per existing comment about node-based agents ignoring `retell_llm_dynamic_variables`) — purely for our records / post-call data joining.

#### (c) Widget-side: data-attribute + identify() API + claim caching

- **`aiw-voice-widget/src/index.ts`** — embed snippet now reads an optional `data-visitor-token` attribute alongside `data-widget-token`. Public API extended: `window.AIWWidget.identify(token)` lets SPAs update the token after their auth completes.
- **`widget.ts`** — new fields `visitorToken: string | null` and `visitorClaims: VisitorClaims | null`. `setVisitorToken(token)` is the implementation backing both transports. Token is passed in every `createSession` body; returned claims are stashed for the inline capture pill's pre-fill path. `InvalidVisitorTokenError` caught with a clear in-widget error message — no auto-retry without the token (that would mask integration bugs).
- **`api.ts`** — `createSession(...)` now takes an optional `visitorToken` parameter; the resulting `SessionResult` includes `visitorClaims`. New `InvalidVisitorTokenError` class differentiates JWT-verify failures from generic session errors.

#### (d) Contact intent + inline capture pill

- **`aiw-voice-widget/src/utils/contact-intent.ts`** (new) — two classifiers tuned conservatively (high precision, lower recall):
  - `detectAgentAsk(text)` — fires only when text ends in `?` and matches curated email/phone keywords. Statements like "I'll email you the receipt" do NOT trigger.
  - `detectUserContact(text)` — looks for a strong contact-shape match (RFC-ish email regex, or a 7-15 digit phone pattern with separators). Catches user-volunteered values mid-conversation.
- **`aiw-voice-widget/src/components/inline-capture.ts`** (new) — shadow-DOM pill below the transcript: label, input (`type="email"` or `"tel"` for correct mobile keyboard), Send + Dismiss buttons. Three display modes — empty / pre-filled from claims / confirm a user-volunteered value. Submit on Enter or button click.
- **Widget integration** — `widget.ts`:
  - Pill constructed in `init()` with `onSubmit` and `onDismiss` callbacks.
  - `observeContactIntent(entries)` runs on every transcript update. Last entry's role dictates: agent-role + intent-match → show with pre-fill from `visitorClaims`; user-role + new text + contact-match → show with confirm-value mode.
  - `handleCaptureSubmit(kind, value)` classifies source (`pre_filled | user_volunteered | agent_asked`), POSTs to `/api/sessions/:id/captures`, appends the value as a user bubble in the transcript (visual feedback that the save landed).
  - `capturedKinds: Set<ContactKind>` ensures we ask once per kind per call — dismissing the pill once doesn't keep popping it up.
- **Migration `0006_session_captures.sql`** — new `session_captures` table (`capture_id` UUID PK, `session_id`, `provider_call_id`, FK `widget_id`, FK `tenant_id`, `kind` CHECK IN `('email','phone')`, `value`, `source` CHECK IN `('agent_asked','user_volunteered','pre_filled')`, `captured_at`). Two indexes: `widget_id`, `provider_call_id`.
- **Schema** — `src/lib/db/schema/session-captures.ts` (new), exported from `schema/index.ts`.
- **Route** — `sessions.routes.ts`: new `POST /api/sessions/:sessionId/captures` (public, widget-token-gated, 60/hour rate limit). Validates body with Zod, looks up widget by `widget_token`, inserts the row, returns `{ capture_id }`. Symmetric protection against scripted abuse.

#### Dashboard UI

- **`aiw-voice-app/src/routes/VisitorIdentityCard.tsx`** (new) — mounted in `WidgetDetailPage` below the triggers panel.
  - When feature is OFF (default): collapsed strip with a one-line description and an "Enable" button. No secret rendered.
  - When ON: expanded card with four sections:
    1. **Your widget secret** — masked display (`abc123…ef89`), show/copy/regenerate buttons. Regenerate behind a confirmation dialog warning that all outstanding tokens will be invalidated.
    2. **Steps for your developer** — five numbered cards with copyable code snippets. Language tabs (Node / Python / PHP / Ruby) drive the install + mint snippets per step.
    3. **Pass the token to the widget** — both transport options (`data-visitor-token` server-render and `window.AIWWidget.identify(token)` SPA path) shown side-by-side.
    4. **Verify it's working** — Test-now input + button that POSTs to the new `visitor-token/verify` endpoint and surfaces decoded claims or a specific error code in a colored result panel.
  - Built on the existing design-system primitives (`Card`, `Button`, `ErrorBanner`).
- **`aiw-voice-app/src/api/widgets.ts`** — three new client methods (`setVisitorIdentityEnabled`, `rotateWidgetSecret`, `verifyVisitorToken`), `Widget` interface extended with `visitorIdentityEnabled` + optional `widgetSecret`.

#### Customer-facing developer doc

- **`aiw-voice-backend/docs/host-site-identify.md`** (new) — comprehensive guide:
  - What identify does (and doesn't do).
  - JWT spec table (required `sub`/`exp`; optional `iat`/`email`/`phone`/`name`; HS256 only).
  - Full copy-pasteable code samples per language with proper error handling.
  - Both transport options with framework-specific notes (ERB / EJS / Jinja / Thymeleaf / Next.js).
  - Step-by-step "verify it's working" via DevTools Network tab.
  - `openssl` one-liner for hand-signing a test JWT.
  - Security guidance (server-side only, per-session tokens, short expiry, HTTPS only, rotate-if-leaked).
  - Troubleshooting matrix for each error code.
  - API reference for the three new endpoints.

### Files added / changed

**Widget**
- New: `src/components/silence-prompt.ts`, `src/components/inline-capture.ts`, `src/utils/contact-intent.ts`
- Modified: `src/api.ts`, `src/components/mic.ts`, `src/components/transcript.ts`, `src/index.ts`, `src/widget.ts`

**Backend**
- New: `docs/host-site-identify.md`, `docs/retell-agent-tuning.md`, `drizzle/0004_seed_plans.sql`, `drizzle/0005_visitor_identity.sql`, `drizzle/0006_session_captures.sql`, `src/lib/db/schema/session-captures.ts`, `src/lib/jwt/visitor-token.ts`
- Modified: `drizzle/meta/_journal.json`, `src/lib/db/schema/index.ts`, `src/lib/db/schema/widgets.ts`, `src/modules/sessions/sessions.routes.ts`, `src/modules/sessions/sessions.schemas.ts`, `src/modules/sessions/sessions.service.ts`, `src/modules/widget/widget.schemas.ts`, `src/modules/widget/widget.service.ts`, `src/modules/widgets/widgets.routes.ts`, `src/modules/widgets/widgets.service.ts`, `src/providers/retell/retell.provider.ts`, `src/providers/types.ts`

**SPA**
- New: `src/routes/VisitorIdentityCard.tsx`
- Modified: `src/api/widgets.ts`, `src/routes/WidgetDetailPage.tsx`

### Verification status

- All three packages: `npx tsc --noEmit` clean as of the worktree sync.
- Widget bundle: 523.6 KB (was 510 KB pre-M2).
- Schema migrations: written, journaled, **NOT yet run against any DB** — pending Docker Desktop + `npm run db:migrate` in `aiw-voice-backend/`.
- **No end-to-end smoke yet** — user's Docker Desktop was off during the implementation session.

### Deploy notes for resumption

- **Run new migrations** (idempotent, safe to re-run): `cd aiw-voice-backend && npm run db:migrate`. Applies `0004_seed_plans` → `0005_visitor_identity` → `0006_session_captures` in order.
- **No new env vars required** for any of the M2 work. Visitor identity uses per-widget secrets stored in the DB, not env config.
- **Customer-facing doc** can be linked from the dashboard's "Visitor identity" card via a "Learn more" link. Current link in the component points at a placeholder GitHub URL — update to the canonical hosting location before showing to customers.
- **Local env gotchas surfaced during the session** (worth re-stating):
  - `aiw-voice-backend/.env`'s `DATABASE_URL` should use `127.0.0.1`, NOT `localhost`, on Windows. `localhost` → IPv6 `::1` → Docker only binds IPv4 → `AggregateError` at `internalConnectMultiple`. CLAUDE.md already documents this; just not yet patched in the live `.env`.
  - `aiw-voice-app/.env.development` ships with `VITE_MARKETING_WIDGET_TOKEN=<placeholder>` — local SPA needs the seed widget token `wt_vdkk_local_dev_token_001` (from `aiw-voice-backend/src/scripts/seed.ts`) and `VITE_MARKETING_WIDGET_BUNDLE_URL=http://localhost:8000/dist/aiw-voice-widget.js`.

### Known parked items (deliberate scope-cuts during planning)

- **Concurrent-session enforcement** — user explicitly dropped in favor of the simpler per-IP 30/hour rate limit alone. Revisit if abuse pattern shows up that hourly rate limits don't catch.
- **Per-tenant monthly minute quota via `usage_counters`** — user dropped from scope. Add when billing goes live.
- **Manual "type it instead" button always available** — user deferred. PR5 only surfaces the pill on detection.
- **Programmatic Retell agent config push** — would let the wizard set `interruption_sensitivity`, `end_call_after_silence_ms`, etc. on agent creation. Until then, `docs/retell-agent-tuning.md` is the source of truth.
- **RNNoise / Krisp in-browser ML noise filter** — only if PR1's browser flags + Retell config don't solve cafe-noise complaints.
- **Multi-language contact-capture detection** — v1 patterns are English only.
- **Retell SDK upgrade for text injection** — Day-0 spike confirmed `retell-client-js-sdk` does NOT expose `sendMessage` or tool-call events. So the capture pill's "save to DB only" path is the only viable v1; agent doesn't "hear" the typed value. Customers see the captured row in `session_captures`. If a future SDK release adds these methods, swap detection → tool-call event and delivery → SDK injection without changing the UX.

---

## Session: Conversations + dashboard + RetellAI integration (2026-05-12)

### Phase A — Dashboard split + sidebar polish

- `/` is now the public landing page for everyone (logged-in users too). Dashboard moved to a dedicated `/dashboard` URL gated by `<ProtectedRoute>`. Solves the "logged-in user has no way back to the marketing site" UX hole.
- Sidebar switched from `position: sticky` to `position: fixed` — no more scroll-with-page glitch. `AppShell` got `paddingLeft: 240` on main to clear it.
- New "Widgets" sidebar item → `/widgets` route renders what the dashboard used to render. Longest-prefix `isActive` keeps `/widgets/new/*` from highlighting both items.
- New stats DashboardPage with 4 cards: Widgets live (+ pending in footnote), Total sessions, Conversation minutes, Conversation of the day (collapsible transcript).
- Login `from`-state validated as a same-origin relative path before navigation (defends against `//evil.com`-style open redirects). All `nav('/')` callsites in OAuth callback, widget detail back, and wizard cancel/exit now point to `/dashboard`.

### Phase B — Conversations backend

- **New tables** (`drizzle/0003_cultured_fixer.sql`):
  - `conversations` — one row per call, keyed `(provider_name, provider_call_id)` so webhook retries are idempotent. Fields: started/ended_at, duration_sec, recording_url, transcript_json + flattened transcript_text (for ILIKE search), language, post_call_analysis JSON, status (`in_progress` / `completed` / `failed`).
  - `provider_webhook_events` — audit log of every webhook hit; `signature_valid` + `processed` columns + raw payload. Rows inserted *before* business logic so spoofing attempts are auditable.
- `sessions.service.ts` inserts an `in_progress` conversation row at session creation (so the row exists before the first webhook fires).
- `retell.provider.ts` injects `widget_id` into Retell `metadata` on every `createWebCall` so webhooks attribute calls without a per-session DB lookup.
- **`POST /api/webhooks/retell`**:
  - Raw body captured by a Fastify `addContentTypeParser('application/json', { parseAs: 'string' })` that stashes `req.rawBody` before `JSON.parse` (signature math needs the exact bytes Retell sent).
  - Verifies HMAC via the **official RetellAI SDK's `Retell.verify(body, apiKey, signature)`** — Retell signs `v={timestamp},d={hex}` where hex is `HMAC-SHA256(body + timestamp, apiKey)`, 5-min replay window. **There is no separate "webhook secret" field on Retell's side**; the signing key IS the account API key. Setup doc at `aiw-voice-backend/docs/retell-webhook-setup.md`.
  - Handles `call_started` (set startedAt + status), `call_ended` (recording_url, duration, disconnect_reason, status=completed), `call_analyzed` (transcript_object → both JSON and flattened text, plus the post-call analysis blob).
- **Conversations + dashboard APIs**:
  - `GET /api/widgets/:widgetId/conversations` — keyset pagination over `(started_at, conversation_id)`, asc/desc sort, date-range filter, ILIKE transcript search; tenant-scoped (404 if widget not owned).
  - `GET /api/dashboard/stats` — widgets live/pending, total sessions, total minutes (`CEIL(duration_sec / 60)` summed), conversation of the day (most recent started today UTC).
- Dropped dead `webhook-events.service.ts` and `webhook-normalizer.ts` from the M1 schema reset.

### Phase C — Conversations UI

- New `/widgets/:widgetId/conversations` page in `src/routes/ConversationsPage.tsx`: sortable date column, debounced transcript search (300ms), date-range filter, IntersectionObserver-driven lazy load (page size 20), per-row transcript expand/collapse (`-webkit-line-clamp: 2` → full pre-wrap), native HTML5 `<audio>` for the recording URL, expandable Details panel rendering post-call analysis as a key/value grid. Total count in topbar subtitle.
- "View Conversations" button on the widget detail chip row (right-aligned via `marginLeft: auto`).
- `DashboardPage` now consumes `dashboardApi.stats()` via TanStack Query with a 60s refetch interval. Quiet inline fallback notice on error so the page never blanks.
- New typed clients: `src/api/conversations.ts` and `src/api/dashboard.ts`.

### Marketing widget auth-gate

- Static `<script>` tag in `index.html` removed. The widget bundle is now injected dynamically by `<MarketingWidget />` in `src/components/MarketingWidget.tsx`, which reads `useAuth()` and only mounts the script tag when `me === null && !loading`. On login transition, the effect cleanup removes the script, the `#aiw-voice-widget` host (so the orb disappears), and `window.AIWWidget`.
- Env knobs (defaults match the old hardcoded values so local dev keeps working): `VITE_MARKETING_WIDGET_BUNDLE_URL`, `VITE_MARKETING_WIDGET_TOKEN`. Backend URL reuses `VITE_API_BASE_URL`.
- HomePage's placeholder floating Orb (the one that opened `<DemoModal>`) removed — it was visually duplicating the real widget orb.

### RetellAI signature fix (commit `f2c61ab`)

Initial verifier expected `HMAC-SHA256(body, RETELL_WEBHOOK_SECRET)` as raw hex. Real Retell webhooks use `v={timestamp},d={hex}` over `body + timestamp` with the **account API key** as the secret. Every legitimate webhook was failing — audit log showed `signature_valid=false` consistently.

- `retell.webhook.ts` now delegates to `Retell.verify()` from the SDK.
- Verifier reads `env.RETELL_API_KEY` (not `RETELL_WEBHOOK_SECRET`).
- `RETELL_WEBHOOK_SECRET` env var stays declared in `env.ts` but is no longer read; Vapi/Bolna can keep their own when those handlers exist.

### Retell agent webhook auto-sync (commit `6c3e373`)

Even with signatures verifying, real calls fired no webhooks because the agent record's `webhook_url` field on Retell's side was `(not set)`. The dashboard's "Agent Level Webhook URL" field didn't actually persist to the agent record — likely a Retell UI bug or it saves to a different scope (account default / workspace template). The "Test Send" button works (uses an account-level test URL) but real calls only deliver to the per-agent `webhook_url`.

- New helper `syncRetellAgentWebhook(agentId)` in `src/providers/retell/retell.agent-sync.ts` — PATCHes the agent via the SDK with `${APP_PUBLIC_BASE_URL}/api/webhooks/retell`. Idempotent; returns `{ok, error}` rather than throwing.
- Wired into `POST /api/admin/widgets/:widgetId/link-agent` so every new agent is auto-configured the moment it's bound to a widget. Best-effort — link succeeds and a warning logs even if Retell is down.
- Two recovery routes (`ADMIN_API_KEY`-gated) for agents linked before this commit:
  - `POST /api/admin/widgets/:widgetId/sync-webhook` — single
  - `POST /api/admin/agents/sync-webhooks` — bulk over every widget with a `retell_agent_id`
- Back-filled the one existing dev agent via the bulk endpoint (1/1 ok). End-to-end verified: subsequent real calls produced 3 audit rows (call_started / call_ended / call_analyzed) with `signature_valid=true, processed=true`, and `conversations` rows populated with `status='completed'`, duration_sec, recording_url, transcript_text.

### Known parked items (NOT done — surfaced for the next session)

- `aiw-voice-app/.env.staging` doesn't yet have `VITE_MARKETING_WIDGET_BUNDLE_URL` or `VITE_MARKETING_WIDGET_TOKEN`. Result: deployed SPA's MarketingWidget defaults to `http://localhost:8000/...` → no orb for actual public visitors of `app.aiworkfllow.com`. **Parked at user's request** — needs a live widget on `aiworkfllow.io` domain first to provide the token.
- TopNav loading flash on `/` (logged-in user briefly sees "Sign in / Get started" CTA before useAuth resolves on hard refresh). Deliberately accepted trade-off in the original plan — flipping to null during loading introduces CLS for the dominant logged-out case. Memory note in `~/.claude/projects/.../memory/project_topnav_loading_flash.md` flags it for revisit if reported in prod.
- `aiw-voice-widget/src/widget.ts` and `aiw-voice-widget/dist/aiw-voice-widget.js` had uncommitted local edits on the user's laptop throughout this session — never touched. WIP for them.

### Deploy notes for resumption

- **DB migration:** `drizzle/0003_cultured_fixer.sql` runs automatically via the `migrate` compose service on `docker compose up -d`. No manual `db:migrate` needed on VPS.
- **Env vars added** (must be set in `/opt/aiw/dev/.env` and `/opt/aiw/prod/.env`): nothing new for the conversations work — `RETELL_API_KEY` was already there. `RETELL_WEBHOOK_SECRET` is no longer read by our code; keep or drop as you like.
- **Per-widget Retell agent `webhook_url`** is now auto-set on link-agent. For existing agents (pre-`6c3e373`), run once after deploy:
  ```bash
  curl -fsS -X POST https://api.aiworkfllow.com/api/admin/agents/sync-webhooks \
    -H "x-admin-key: $(grep -E '^ADMIN_API_KEY=' /opt/aiw/dev/.env | cut -d= -f2-)"
  ```
- **Cleanup done this session:** `feat/dashboard-stats-phase-a` (local + `origin/`) and `claude/keen-heyrovsky-9db09b` deleted after merge. The corresponding worktree directory deleted from `.claude/worktrees/`.

---

## Older sessions ↓

> Below is the prior handoff context (OAuth + DEV deploy from 2026-05-02). Kept verbatim for orientation.

---

## What was built in this session

### Google + LinkedIn OAuth (social sign-in / sign-up)

Both providers are wired end-to-end and working in the local dev environment.

**Flow:**
1. User clicks "Google" or "LinkedIn" button on `/login` or `/signup`
2. Browser hits `GET /api/auth/google?from=login` (or `signup`) on the backend
3. Backend generates an HMAC-signed state token and redirects to the provider's auth URL
4. Provider redirects back to `GET /api/auth/google/callback?code=…&state=…`
5. Backend exchanges code for access token, fetches email + provider ID, upserts user
6. Backend mints a JWT and redirects to `{APP_FRONTEND_URL}/auth/callback?token=…`
7. `OAuthCallbackPage.tsx` reads the token, calls `login(token)`, navigates to `/`

**Account linking logic** (in `oauth.service.ts → oauthUpsertUser()`):
- Look up by `(oauthProvider, oauthProviderId)` — returning OAuth user, sign them in
- Look up by email — existing password account, link it (set oauth columns, mark verified)
- Neither found — create new tenant + user (OAuth-only, `passwordHash = null`)

---

## Files changed / created

### Backend (`aiw-voice-backend/`)

| File | Change |
|------|--------|
| `src/lib/db/schema/users.ts` | `passwordHash` nullable; added `oauthProvider`, `oauthProviderId` columns |
| `drizzle/0001_oauth_columns.sql` | **New.** Migration: drop NOT NULL on password_hash, add oauth columns + conditional unique index |
| `drizzle/meta/_journal.json` | Added entry for `0001_oauth_columns` |
| `src/config/env.ts` | Added `GOOGLE_CLIENT_ID/SECRET`, `LINKEDIN_CLIENT_ID/SECRET`, `APP_FRONTEND_URL` |
| `src/modules/auth/oauth.service.ts` | **New.** State HMAC helpers, Google/LinkedIn URL builders + code exchange, `oauthUpsertUser()` |
| `src/modules/auth/auth.routes.ts` | Added 4 OAuth routes (initiate + callback for each provider) |
| `src/modules/auth/auth.service.ts` | `login()` now checks for null `passwordHash` → returns `USE_SOCIAL_LOGIN` error |
| `.env.example` | Added OAuth env var placeholders |

### Frontend (`aiw-voice-app/`)

| File | Change |
|------|--------|
| `src/routes/OAuthCallbackPage.tsx` | **New.** Reads `?token=` from URL, calls `login()`, navigates to `/` |
| `src/routes/AppRouter.tsx` | Added `<Route path="/auth/callback" element={<OAuthCallbackPage />} />` |
| `src/routes/LoginPage.tsx` | Added `useSearchParams`, `GoogleIcon`, `LinkedInIcon`, OAuth buttons, error display |
| `src/routes/SignupPage.tsx` | Same additions as LoginPage; buttons pass `?from=signup` |

---

## Environment setup

### Backend (`aiw-voice-backend/.env`)
```env
# Existing
PORT=3002
DATABASE_URL=postgresql://aiw_voice_user:change_me@127.0.0.1:5432/aiw_voice
JWT_SECRET=your_jwt_secret
APP_PUBLIC_BASE_URL=http://localhost:3002

# OAuth — added in this session
GOOGLE_CLIENT_ID=<from Google Cloud Console>
GOOGLE_CLIENT_SECRET=<from Google Cloud Console>
LINKEDIN_CLIENT_ID=<from LinkedIn Developer Portal>
LINKEDIN_CLIENT_SECRET=<from LinkedIn Developer Portal>
APP_FRONTEND_URL=http://localhost:5173
```

> **Important:** `DATABASE_URL` must use `127.0.0.1` not `localhost` when Docker is bound to 127.0.0.1:5432 on Windows. `localhost` may fail with ECONNREFUSED.

### Frontend (`aiw-voice-app/.env.development`)
```env
VITE_API_BASE_URL=http://localhost:3002
```
No OAuth secrets in the frontend — the backend-redirect flow handles everything server-side.

---

## Developer Portal configuration required

### Google (Cloud Console → APIs & Services → Credentials)
- OAuth 2.0 Client ID type: **Web application**
- Authorized redirect URI: `http://localhost:3002/api/auth/google/callback`
- For production: add your real domain's callback URL

### LinkedIn (developers.linkedin.com → Your App)
1. **Auth tab → OAuth 2.0 settings → Authorized Redirect URLs:**  
   Add `http://localhost:3002/api/auth/linkedin/callback`
2. **Products tab:**  
   Add **"Sign In with LinkedIn using OpenID Connect"** and wait for approval  
   (Without this, LinkedIn shows "Bummer, something went wrong" on their consent page)

---

## How to run locally

```bash
# 1. Backend
cd aiw-voice-backend
npm run db:migrate          # applies 0000 + 0001 migrations
npm run db:seed             # creates dev@example.com / devpass123
npm run dev                 # :3002

# 2. Frontend (separate terminal)
cd aiw-voice-app
npm run dev                 # :5173

# Open http://localhost:5173/signup or /login
# Google and LinkedIn buttons should be visible and functional
```

---

## Architecture decisions made

| Decision | Rationale |
|----------|-----------|
| Backend-redirect OAuth (not client-side PKCE) | Client secrets never reach the browser |
| Stateless HMAC state (no session/cookie) | Keeps backend stateless; JWT_SECRET already available |
| Account linking by email (default) | Avoids duplicate accounts when user changes sign-in method |
| Native `fetch` for token exchange | Node 20 built-in; no new npm dependency needed |
| `passwordHash` nullable, not separate table | Simpler schema; OAuth-only users just have `null` hash |
| Error redirects to originating page | `state` encodes `from` so errors go back to `/login` or `/signup` |

---

---

# 🚧 Current Work-in-Progress: VPS Deployment (Dev + Prod)

> **Full plan:** `.claude/plans/snappy-leaping-willow.md` — read it first if resuming this work.

## TL;DR
Deploy the three packages to **two environments on the same cPanel/WHM server**:
- **Dev** → `app.aiworkfllow.com`, `api.aiworkfllow.com`, `widget.aiworkfllow.com`
- **Prod** → `app.aiandhumn.com`, `api.aiandhumn.com`, `widget.aiandhumn.com`

## Critical server facts (for resumption)

| Fact | Detail |
|---|---|
| **Server type** | cPanel/WHM with EasyApache nginx (EA-nginx) on the host. NOT vanilla Linux. |
| **Server IP** | `65.254.80.37` |
| **nginx location** | Host system service (not Docker). Owns ports 80/443. |
| **cPanel user for dev domain** | `aiworkfllow` (existing) |
| **Prod domain status** | `aiandhumn.com` is NOT yet a cPanel account — must add via WHM |
| **WHM access** | User has root |
| **Working Docker-app-behind-cPanel template** | `n8n.aiworkfllow.com` (already proxies to a Docker container — exact pattern to mirror) |
| **Other tenants on server** | ~25 client production sites (medical, dental, legal). All changes MUST be additive and scoped to the `aiworkfllow` cPanel user via `/etc/nginx/conf.d/users/aiworkfllow/*.conf` |
| **Stale legacy container** | `aiw-voice-backend-api-1` (10 days old, no `users` table, pre-OAuth code). Empty DB. Safe to retire. |
| **Other unrelated containers** | `n8n` (crash-looping, ignore), `mongo`, `qdrant`, `hospital-pg-proxy`. Leave alone. |

## Decisions locked in

| Decision | Choice | Why |
|---|---|---|
| VPS topology | Same VPS, both envs | Stack is small; lift-and-shift to dedicated VPS later if needed |
| Subdomain layout | Split: `app.`, `api.`, `widget.` per domain | Clean CORS, future-proof for CDN |
| Reverse proxy | Reuse host nginx (cPanel-managed) via user-include configs | Survives cPanel rebuilds; AutoSSL handles certs |
| Deploy flow | Manual SSH now, GitHub Actions later | Faster to first deploy |
| Prod hosting | Stay on same VPS for now | Avoid AWS/Azure complexity until justified by scale or compliance |
| OAuth apps | Separate Google + LinkedIn apps per env | Different callback URLs require it |
| Postgres | Separate container + volume per env | Full data isolation |
| Networks | Disjoint Docker networks per env | Dev API can never reach prod DB |

## DEV deploy — LIVE ✅ (2026-05-02)

Verified end-to-end on `aiworkfllow.com`:
- ✅ All 3 subdomain certs issued via AutoSSL
- ✅ Stack running: `aiw-dev-postgres` (healthy) + `aiw-dev-migrate` (exited 0) + `aiw-dev-api` + `aiw-dev-app` + `aiw-dev-widget`
- ✅ HTTPS smoke tests: health, app index, app SPA deep-link (`/signup`), widget bundle
- ✅ API smoke tests: signup → 201 + verification email actually delivered, login blocks with `EMAIL_NOT_VERIFIED`, Google + LinkedIn OAuth initiate → 302 to provider, CORS preflight returns 204 with allowlist origin
- ✅ Browser flows: email signup + verification + click-link, Google signup, LinkedIn signup, login, logout

## Lessons learned during dev deploy (folded into the repo)

| Problem | Symptom | Fix |
|---|---|---|
| `location ^~ /` collides with cPanel's `location /` | `nginx: [emerg] duplicate location "/"` on reload | Per-subdomain path-specific overrides: api uses `^~ /api/`, `^~ /admin`, `= /health`; **app uses regex `~ ^/(?!\.well-known/)`** (regex doesn't collide with prefix locations, lookahead preserves AutoSSL paths); widget uses `= /aiw-voice-widget.js`. Fixed in 6 files in commit `9836ef5`. |
| `COPY /app/data` fails on fresh clones | `failed to compute cache key: ... "/app/data": not found` | Removed COPY in commit `2accb8b`. `data/` is gitignored, only used by disabled modules. |
| `tsc && vite build` hard-fails on TS6133 even though `tsc --noEmit` passed | `error TS6133: 'wordmarkSrc' is declared but its value is never read.` | Removed unused import in commit `0de2932`. |
| Compose relative paths break under symlinks | `unable to prepare context: path "/opt/aiw-voice-backend" not found` | **Workaround in place on VPS only**: `/opt/aiw/dev/docker-compose.yml` is a real file (not symlink), with `context:` paths sed-patched to `/opt/aiw/src/aiw-voice-backend` etc. **Repo not yet fixed** — see deferred tasks. |

## Tools / SSH / runtime context

- VPS access: SSH as `root@65.254.80.37`. cPanel user `aiworkfllow` also has Docker access (rootless or in `docker` group).
- Repo cloned at `/opt/aiw/src/`
- Per-env stacks at `/opt/aiw/dev/` and `/opt/aiw/prod/` (compose files are sed-patched copies, NOT symlinks — see lesson above)
- Storage volumes bind-mounted from `/opt/aiw/<env>/storage/`
- Deploy command: `cd /opt/aiw/dev && docker compose up -d --build`
- Pull updates: `cd /opt/aiw/src && git pull origin main`. **NOTE**: this updates source code but does NOT update the patched compose file. To pick up compose changes, re-`cp` from src and re-apply the `sed` patch (until the repo-side fix lands).

## How to deploy PROD (when ready)

User must complete out-of-band tasks first (top of "Required by user" section). Then on VPS:

```bash
# Lay out prod folder (mirroring dev)
cp /opt/aiw/src/deploy/prod/docker-compose.yml /opt/aiw/prod/docker-compose.yml
sed -i 's|context: \.\./\.\./aiw-voice-|context: /opt/aiw/src/aiw-voice-|g' /opt/aiw/prod/docker-compose.yml

# Generate prod .env (use SAME heredoc pattern as dev with prod URLs/secrets)
# See deploy/prod/.env.example as the template, /opt/aiw/dev/.env as a structural reference

# Build + run
cd /opt/aiw/prod
docker compose build
docker compose up -d

# Install prod proxy includes
sudo mkdir -p /etc/nginx/conf.d/users/aiandhumn/{api,app,widget}.aiandhumn.com
sudo cp /opt/aiw/src/deploy/cpanel-nginx/aiandhumn/api.aiandhumn.com/proxy.conf  /etc/nginx/conf.d/users/aiandhumn/api.aiandhumn.com/
sudo cp /opt/aiw/src/deploy/cpanel-nginx/aiandhumn/app.aiandhumn.com/proxy.conf  /etc/nginx/conf.d/users/aiandhumn/app.aiandhumn.com/
sudo cp /opt/aiw/src/deploy/cpanel-nginx/aiandhumn/widget.aiandhumn.com/proxy.conf  /etc/nginx/conf.d/users/aiandhumn/widget.aiandhumn.com/
sudo nginx -t && sudo systemctl reload nginx

# Repeat smoke tests against api.aiandhumn.com etc.
```

---

## Code changes — status (all complete ✅)

| Item | Status |
|---|---|
| `aiw-voice-app/.env.development` | ✅ Already correct (`http://localhost:3002`) |
| `aiw-voice-app/.env.staging` | ✅ Created (`https://api.aiworkfllow.com`) |
| `aiw-voice-app/.env.production` | ✅ Created (`https://api.aiandhumn.com`) |
| `aiw-voice-app/.env` | ✅ Confirmed does not exist (no leaked secrets) |
| `aiw-voice-app/Dockerfile` + `nginx.conf` + `.dockerignore` | ✅ Multi-stage node-builder + nginx-static, BrowserRouter `try_files` fallback, gzip, asset cache headers |
| `aiw-voice-widget/Dockerfile` + `nginx.conf` + `.dockerignore` | ✅ Multi-stage esbuild + nginx-static with permissive CORS, 5-min cache |
| `aiw-voice-backend/Dockerfile` | ✅ Patched: `ENV STORAGE_BASE_PATH=/app/storage`, `mkdir -p /app/storage`, dist contains `lib/db/migrate.js` (verified by tsc) |
| `aiw-voice-backend/.dockerignore` | ✅ Expanded to exclude storage, .env*, .git, etc. |
| `aiw-voice-backend/src/plugins/cors.ts` | ✅ Per-route allowlist via @fastify/cors delegator pattern. Public widget endpoints (`/api/widget/`, `/api/sessions`) get `origin: true`; everything else uses `WIDGET_ALLOWED_ORIGINS`. Typecheck passes. |
| `deploy/dev/docker-compose.yml` | ✅ 5 services: postgres, migrate (one-shot, blocks api), api, app, widget. Project name `aiw-dev`, network `aiw-dev-net`, volume `aiw-dev-pgdata` |
| `deploy/prod/docker-compose.yml` | ✅ Same shape; project `aiw-prod`, network `aiw-prod-net`, volume `aiw-prod-pgdata`. Different host ports |
| `deploy/dev/.env.example` + `deploy/prod/.env.example` | ✅ Full templates with comments; each has its own subdomain URLs and warns against cross-env secret reuse |
| `deploy/cpanel-nginx/aiworkfllow/{app,api,widget}.aiworkfllow.com/proxy.conf` | ✅ 3 files using `location ^~ /` override pattern, `client_max_body_size 25M` on api |
| `deploy/cpanel-nginx/aiandhumn/{app,api,widget}.aiandhumn.com/proxy.conf` | ✅ 3 files, same pattern, prod ports |
| `deploy/README.md` | ✅ Full runbook: one-time setup (DNS/WHM/OAuth/SMTP/secrets), first deploy dev, first deploy prod, subsequent deploys, troubleshooting |
| `.gitignore` | ✅ Added `!/deploy/` allowlist + SPA env-file allow exceptions + `deploy/**/.env` and `deploy/**/storage/` blocks |

## Service port allocation on the VPS

| Service | Dev port (loopback) | Prod port (loopback) |
|---|---|---|
| `app` (SPA, nginx static) | `127.0.0.1:5173` | `127.0.0.1:5183` |
| `api` (Fastify) | `127.0.0.1:3010` | `127.0.0.1:3020` |
| `widget` (static JS) | `127.0.0.1:5174` | `127.0.0.1:5184` |
| `postgres` | internal Docker net only | internal Docker net only |

(Existing `aiw-voice-backend-api-1` currently uses `127.0.0.1:3000` — will be retired before dev stack starts.)

## Required by user (out-of-band)

These are tasks only the user can do — capture for the next chat:

1. **Add `aiandhumn.com` as cPanel account** (WHM → Create New Account) — pick a username like `aiandhumn`
2. **DNS — point 6 A-records to `65.254.80.37`**:
   - `app.aiworkfllow.com`, `api.aiworkfllow.com`, `widget.aiworkfllow.com`
   - `app.aiandhumn.com`, `api.aiandhumn.com`, `widget.aiandhumn.com`
3. **Create subdomains in cPanel UI** (or WHM API) for both accounts → triggers vhost + AutoSSL
4. **Register OAuth apps** — separate Google + LinkedIn for each env (callback URIs are `https://api.<domain>/api/auth/{google,linkedin}/callback`)
5. **Pick SMTP provider** (Resend/Postmark/SES) — verify sending domain for both `aiworkfllow.com` and `aiandhumn.com`
6. **Generate strong secrets per env**: `openssl rand -hex 48` for `JWT_SECRET` and `ADMIN_API_KEY`

## cPanel proxy pattern — REFERENCE (extracted from n8n.aiworkfllow.com)

cPanel auto-generates each subdomain vhost in `/etc/nginx/conf.d/users/<cpanel_user>.conf`. Each generated `server { }` block ends with three `include` directives:

```nginx
include conf.d/server-includes/*.conf;                       # global, all vhosts
include conf.d/users/aiworkfllow/*.conf;                     # all aiworkfllow vhosts
include conf.d/users/aiworkfllow/<subdomain>/*.conf;         # this subdomain only
```

So our **proxy includes belong in the per-subdomain folder**:
- `/etc/nginx/conf.d/users/aiworkfllow/api.aiworkfllow.com/proxy.conf`
- `/etc/nginx/conf.d/users/aiworkfllow/app.aiworkfllow.com/proxy.conf`
- `/etc/nginx/conf.d/users/aiworkfllow/widget.aiworkfllow.com/proxy.conf`
- `/etc/nginx/conf.d/users/aiandhumn/api.aiandhumn.com/proxy.conf`
- `/etc/nginx/conf.d/users/aiandhumn/app.aiandhumn.com/proxy.conf`
- `/etc/nginx/conf.d/users/aiandhumn/widget.aiandhumn.com/proxy.conf`

### Override technique
cPanel's auto-generated block defines `location / { proxy_pass $CPANEL_APACHE_PROXY_PASS; }` (which sends traffic to Apache). To take over the subdomain entirely, use `location ^~ /` in our include — the `^~` modifier gives our block priority over cPanel's `location /`.

Why `^~` works: nginx location precedence is `=` > `^~` prefix > regex > longest prefix. The `^~ /` block matches AND stops further searching, so cPanel's `location /` is bypassed.

n8n's setup uses path-specific overrides (`location ^~ /rest/push`, etc.) because n8n's UI is served via Apache reverse-proxy. **Our case is different — we want the entire subdomain bypassed to Docker** — hence `location ^~ /`.

### Template (drop-in for each subdomain)

```nginx
# /etc/nginx/conf.d/users/<cpanel_user>/<subdomain>/proxy.conf
# Override cPanel default. Route entire subdomain to a Docker container on loopback.

location ^~ / {
    proxy_pass http://127.0.0.1:<PORT>;
    proxy_http_version 1.1;

    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host  $host;

    # WebSocket-friendly (harmless for plain HTTP)
    proxy_set_header Upgrade           $http_upgrade;
    proxy_set_header Connection        "upgrade";

    proxy_buffering    off;
    proxy_cache        off;
    proxy_read_timeout 300;
    proxy_send_timeout 300;
}
```

### Per-subdomain port mapping
Substitute `<PORT>` per the table:

| Subdomain | PORT |
|---|---|
| `api.aiworkfllow.com` | `3010` |
| `app.aiworkfllow.com` | `5173` |
| `widget.aiworkfllow.com` | `5174` |
| `api.aiandhumn.com` | `3020` |
| `app.aiandhumn.com` | `5183` |
| `widget.aiandhumn.com` | `5184` |

### After dropping configs
```bash
sudo nginx -t && sudo systemctl reload nginx
```

### Special considerations for `api.<domain>`
The backend handles file uploads (KB documents, ≤25 MB). Add to its `proxy.conf` a server-level setting via a sibling file `client_size.conf`:
```nginx
client_max_body_size 25M;
```
(Server-level directives can't go inside a `location` block; this needs to be at server scope. Either drop it as a separate `.conf` next to `proxy.conf`, or put both directives in the same file at the top.)

### TLS / certificates
cPanel's AutoSSL handles Let's Encrypt cert issuance automatically when:
1. The subdomain exists in cPanel
2. DNS resolves to `65.254.80.37`
3. AutoSSL has run (manual trigger: WHM → SSL/TLS → Manage AutoSSL → "Check 'aiworkfllow' Now")

No certbot work needed.

---

## Reverse-proxy info — fully captured ✅

All VPS info is in `handoff.md` now. Next chat can write all 6 nginx configs without further VPS access.

---

## ⚠️ Widget hosting is critical-path, not "nice to have"

The current production embed snippet `<script src="https://api.aiworkfllow.com/dist/aiw-voice-widget.js" ...>` **does not work today**. Three compounding reasons (verified by reading `aiw-voice-backend/src/app.ts`):

1. **Backend has no `/dist/` static handler.** It only registers `fastifyStatic` for `/admin/`. So the URL above 404s — the widget JS never loads, no orb appears.
2. **The widget bundle is not in the backend's Docker image.** `aiw-voice-backend/Dockerfile` only copies its own `dist/`, never `aiw-voice-widget/dist/aiw-voice-widget.js`.
3. **The legacy `aiw-voice-backend-api-1` container is pre-OAuth and pre-schema-rewrite.** Its DB has no `users`/`widgets` tables, so even if the JS loaded, `/api/widget/resolve` would fail.

**The deployment fixes all three at once:**
- A dedicated `widget` service in compose serves the freshly-built bundle at `widget.aiworkfllow.com/aiw-voice-widget.js` (Dockerfile = esbuild → nginx static, CORS-permissive)
- The new `api` service runs the latest backend with full auth + widget routes + SMTP + OAuth
- A fresh `postgres` + one-shot `migrate` service applies all migrations (`0000_initial.sql` + `0001_oauth_columns.sql`) before `api` starts

Customer embed snippet AFTER deploy will be:
```html
<script
  src="https://widget.aiworkfllow.com/aiw-voice-widget.js"
  data-widget-token="wt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
  data-backend-url="https://api.aiworkfllow.com"
></script>
```

The widget source itself (`aiw-voice-widget/src/widget.ts`) already auto-creates the orb at `position: fixed; bottom: 24px; right: 24px;` on script load via `widget.init()`. **No code changes needed in the widget — the bug is delivery-pipeline, not product.**

Plug-and-play behaviour the user wants (orb appears → click → voice chat opens) is already implemented. We just need to actually deploy.

## How to resume in a fresh chat

1. Read `CLAUDE.md` (full project + deployment context)
2. Read `handoff.md` (this file — status of in-progress work)
3. Read `.claude/plans/snappy-leaping-willow.md` (the approved deployment plan)
4. Check todo list / `git status` to see what's already done
5. Continue from the first unchecked item in the **Code changes — status** table above

---

## What's NOT built yet (future sessions)

- **Password reset** — forgot password → email with reset link → `/reset-password` form
- **Multi-user tenants** — today: 1 user per tenant; need invite/role system
- **Billing** — no subscription gating yet
- **Analytics + transcripts** — modules exist in git history but disabled (schema rewrite needed)
- **OCR** for scanned PDFs in KB pipeline
- **Automated tests** — no test suite configured (no Jest/Vitest)

---

# 🎯 Behavior Triggers — auto-open the widget on visitor signals

> **Goal:** customer-configurable rules that open the existing voice chat automatically based on visitor behavior on their site (dwell time, scroll depth, exit intent, etc.). Adds a new "Triggers" tab in the widget detail page where customers pick from preset rules or define custom ones.

## Design decisions locked in (2026-05-05)

| # | Decision | Choice |
|---|---|---|
| 1 | **Action type** | Single action — open the existing voice chat (same flow as a manual orb click). No `pulse`/`prompt` UI. No greeting override (agent says its configured greeting). |
| 2 | **Default state for new widgets** | All triggers **disabled** by default. Customer enables/configures per widget in dashboard. |
| 3 | **Storage** | `localStorage` primary, `sessionStorage` fallback when blocked. Repeat-visitor signal won't function in incognito (acceptable). |
| 4 | **Section-level tracking** | Deferred to **Phase 4** (will need AI for greeting based on hovered section). |
| 5 | **Analytics table** | Deferred to **M3**. |
| 6 | **Bundle size** | OK with ~6–8 KB increase (502 → ~510 KB gzipped). |

## Signal taxonomy (5 in M1, 4 more in M2)

| # | Signal | M1 | M2 | Detection mechanism |
|---|---|---|---|---|
| 1 | Dwell time | ✅ | | `setInterval(1s)` while `document.visibilityState === 'visible'` |
| 2 | Scroll depth | ✅ | | passive `scroll` listener, throttled, tracks running max |
| 3 | Repeated section views | | ✅ | page divided into deciles, count visits per decile |
| 4 | Exit intent | ✅ | | `mouseleave` on document with `clientY < 5` + tab-hidden fallback |
| 5 | Idle time | ✅ | | reset on scroll/mouse/key/touch; threshold check via interval |
| 6 | Form abandonment | | ✅ | document-level `focusin` on inputs, no `submit` within 30s of `focusout` |
| 7 | Repeat visitor | ✅ | | `localStorage['_aiw_visitor_<token>']` — `{firstSeenTs, lastSeenTs, visitCount}` |
| 8 | Page sequence | | ✅ | `sessionStorage['_aiw_page_seq']` array, wildcard pattern matching |
| 9 | Source | | ✅ | `document.referrer` + URL params (`utm_*`, `gclid`, `fbclid`, `msclkid`) |

## Rule engine

```ts
interface TriggerRule {
  id: string;
  name: string;
  enabled: boolean;
  conditions: Condition[];      // AND of all (M1); OR / sequence in M4
  cooldown?: { perSession?: boolean; perDays?: number };
  priority?: number;
}
```

Hybrid evaluation: `setInterval(500ms)` for continuous signals + immediate eval on discrete ones (`exitIntent`, `formAbandoned`). First matching rule by priority wins, fires once per session unless cooldown elapsed. Fires `widget.startCall(null)` — same as a manual click.

## 5 presets shipped in M1

| Preset name | Conditions |
|---|---|
| Stuck on pricing | `urlMatches: */pricing*` AND `dwellSeconds ≥ 45` AND `idleSeconds ≥ 15` |
| About to leave | `exitIntent` AND `dwellSeconds ≥ 10` |
| Returning visitor | `repeatVisitor` AND `visitCount ≥ 2` |
| Engaged reader | `scrollDepth ≥ 0.7` AND `dwellSeconds ≥ 60` |
| (Idle reader) | `dwellSeconds ≥ 30` AND `idleSeconds ≥ 20` |

(Form-friction and paid-ad-visitor presets ship in M2 once their underlying signals do.)

## Backend schema

```sql
-- 0002_widget_triggers.sql
CREATE TABLE widget_triggers (
  trigger_id   UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  widget_id    UUID NOT NULL REFERENCES widgets(widget_id) ON DELETE CASCADE,
  name         TEXT NOT NULL,
  enabled      BOOLEAN NOT NULL DEFAULT false,
  priority     INT NOT NULL DEFAULT 0,
  conditions   JSONB NOT NULL,
  cooldown     JSONB,
  created_at   TIMESTAMPTZ DEFAULT now(),
  updated_at   TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX widget_triggers_widget_idx ON widget_triggers(widget_id);
```

## API surface

| Method | Path | Notes |
|---|---|---|
| `GET` | `/api/widgets/:id/triggers` | list, requireAuth |
| `POST` | `/api/widgets/:id/triggers` | create, requireAuth |
| `PUT` | `/api/widgets/:id/triggers/:triggerId` | update, requireAuth |
| `DELETE` | `/api/widgets/:id/triggers/:triggerId` | delete, requireAuth |
| `GET` | `/api/widget/resolve` | **modified** — response now includes `triggers: TriggerRule[]` (only `enabled=true`, ordered by priority) |

## Phased roadmap

### M1 — Core tracker + 5 presets (in progress)
- [ ] Schema migration `0002_widget_triggers.sql` + `_journal.json`
- [ ] Drizzle schema `lib/db/schema/widget-triggers.ts`
- [ ] Zod validators for `Condition[]` + cooldown shape
- [ ] CRUD service + routes mounted under widgets module
- [ ] Extend `widget.service.resolveWidget()` to return enabled triggers
- [ ] Widget signal modules: dwell, scroll, exitIntent, idle, repeatVisitor
- [ ] `RuleEngine.ts` (AND-only, cooldown enforcement)
- [ ] `BehaviorTracker.ts` orchestrator
- [ ] Wire `BehaviorTracker` into `widget.ts` `init()`; on rule match → `startCall(null)`
- [ ] SPA: `api/widgets.ts` trigger CRUD client
- [ ] SPA: new `TriggersTab.tsx` with 5 preset cards + enable/disable
- [ ] SPA: mount Triggers tab in `WidgetDetailPage.tsx`
- [ ] Typecheck all three packages
- [ ] Build widget bundle
- [ ] Manual smoke test on togglebytes.com test page

### M2 — Remaining signals + custom rule editor
- [ ] Signal modules: form abandonment, page sequence, source attribution
- [ ] Custom rule editor in TriggersTab (condition dropdown + threshold inputs)
- [ ] Form-friction + paid-ad-visitor presets
- [ ] Human-readable rule summary on each card

### M3 — Analytics + outcome tracking
- [ ] `trigger_events` table (widget_id, trigger_id, fired_at, outcome, page_url)
- [ ] `POST /api/widget/trigger-fired` (no-auth, public)
- [ ] Analytics tab on widget detail page (fires per rule, conversion per rule)
- [ ] DNT (`navigator.doNotTrack`) honored — no analytics POST when set

### M4 — Section-level + multi-condition combinators (uses AI)
- [ ] `data-aiw-section` DOM attribute scheme for section landmarks
- [ ] Section-aware visit tracking
- [ ] AI-generated greeting based on which section is in view at trigger time
- [ ] OR groups + sequence-with-time-window combinators (`A then B within 30s`)

## Privacy guardrails (M1)

- No PII captured in signals (`formAbandoned` records "form X engaged" — never form contents).
- localStorage scoped per `widgetToken` (no cross-site identity).
- DNT respected when analytics POST lands in M3.
- Customer kill-switch: `disableBehaviorTriggers` flag on widget config (M2).
- Visible affordance on auto-open (M2): small "Why am I seeing this?" link in modal.

## Key files (M1)

```
aiw-voice-widget/src/behavior/
  BehaviorTracker.ts           orchestrator — start/stop, owns signal instances
  RuleEngine.ts                evaluator — AND of conditions, cooldown
  storage.ts                   localStorage + sessionStorage fallback
  types.ts                     TriggerRule, Condition, signal-state shapes
  signals/
    dwell.ts
    scroll.ts
    exitIntent.ts
    idle.ts
    repeatVisitor.ts

aiw-voice-backend/
  drizzle/0002_widget_triggers.sql
  src/lib/db/schema/widget-triggers.ts
  src/modules/widgets/
    triggers.routes.ts          mounted under /api/widgets/:id/triggers
    triggers.service.ts
    triggers.validators.ts

aiw-voice-app/src/routes/widget-detail/
  TriggersTab.tsx               new tab body
  TriggerPresetCard.tsx         single preset row with enable/disable
```

---

## Key files for orientation

```
aiw-voice-backend/
  src/modules/auth/
    auth.routes.ts          ← all auth HTTP routes (email + OAuth)
    auth.service.ts         ← email/password login, signup, email verify
    oauth.service.ts        ← Google/LinkedIn OAuth logic
    auth.hook.ts            ← requireAuth Fastify preHandler
  src/lib/db/schema/
    users.ts                ← users table (with oauth columns)
    tenants.ts              ← tenants table
  drizzle/
    0000_initial.sql        ← initial schema
    0001_oauth_columns.sql  ← oauth columns migration

aiw-voice-app/
  src/auth/
    AuthProvider.tsx        ← login(token)/logout, persists JWT in localStorage
  src/routes/
    LoginPage.tsx           ← email + OAuth sign-in
    SignupPage.tsx          ← email + OAuth sign-up
    OAuthCallbackPage.tsx   ← /auth/callback handler
    AppRouter.tsx           ← all client-side routes
  src/api/
    auth.ts                 ← authApi.login/signup/resend calls
    client.ts               ← fetch wrapper with JWT injection
```

---

## Pitfall — Scroll-reveal class drift in `PublicLayout` _(2026-05-18)_

**Symptom.** New animated cards added to `/how-it-works` rendered into the DOM correctly but were invisible in the browser. No console error, no Vite HMR error, no React error boundary triggered. Hard refresh + dev-server restart didn't help. The cards were stuck at `opacity: 0`.

**Root cause.** `aiw-voice-app/src/components/PublicLayout.tsx` (the wrapper used by every non-homepage public route — `/how-it-works`, `/about`, `/pricing`, `/blog`, `/investors`, `/login`, `/signup`) ran an `IntersectionObserver` that added the class `'revealed'` when an element scrolled into view. But `aiw-voice-app/src/routes/landing/styles/landing.css` only defines the *visible* state under `.reveal.is-visible` and `.reveal-stagger.is-visible > *`. There is no `.revealed` rule anywhere in the codebase. So the observer fired, added a no-op class, and `.reveal-stagger > *` stayed at its default `opacity: 0`. The bug had been latent since `PublicLayout` was written — meaning the "Under the hood" cards on `/how-it-works` and every other `.reveal*` element on public pages have been silently invisible to all visitors.

**Why the homepage was fine.** `HomePage.tsx` has its own `IntersectionObserver` (lines 33–51) that correctly adds `'is-visible'`. Only the `PublicLayout`-wrapped routes were affected.

**Fix.** One-character change in `PublicLayout.tsx` line 15: `classList.add('revealed')` → `classList.add('is-visible')`. Unblocks every reveal element on the affected routes.

**Lesson — guardrails to prevent recurrence.**
1. The scroll-reveal class name is part of the public CSS API (`landing.css`) and **must** be `is-visible`. Any new `IntersectionObserver` that toggles `.reveal` / `.reveal-stagger` MUST use `'is-visible'`. This is now documented as a Key Convention in `CLAUDE.md`.
2. When the visible-state class and the observer-added class drift, the browser gives zero feedback — no error, no warning, no devtools indicator. The only signal is "elements I expect to see are invisible." When that symptom hits, the first place to look is the observer code path, not the React render, not the CSS imports, not the build pipeline.
3. Pattern for verifying a new public-route scroll-reveal works: in DevTools Elements panel, scroll the page and watch the target element's `class` attribute live. If it gains `is-visible`, CSS is wired right. If it gains some *other* class, the observer is wrong.
4. If a future refactor consolidates the two observers (one in `HomePage.tsx`, one in `PublicLayout.tsx`) into a shared hook, keep the class name string as a single exported constant so it can't drift again.

**Files touched by the fix.** Only `aiw-voice-app/src/components/PublicLayout.tsx`. No CSS change, no schema change, no env change.
