← back to Professional Directory
snapshot: 56 file(s) changed, +46 new, ~10 modified
15d88dc762b94beebdab4d7387d74abc4925c5fa · 2026-05-13 08:57:59 -0700 · Steve
Files touched
A CHANGES.mdA REVIEW-2026-05-04.mdA agents/api-agent/MOCKUP_VARIANTS.mdA agents/api-agent/auth.jsA agents/api-agent/data_market.jsA agents/api-agent/routes/admin-pitch.jsA agents/api-agent/routes/auth.jsA agents/api-agent/routes/chat.jsA agents/api-agent/routes/claims.jsA agents/api-agent/routes/messages.jsA agents/api-agent/routes/reviews.jsA agents/api-agent/routes/subscriptions.jsA agents/api-agent/routes/threads.jsM agents/api-agent/server.jsA agents/crawler-agent/crawlers/front-page.jsA agents/crawler-agent/server.jsA agents/dedupe-agent/server.jsA agents/enrich-agent/server.jsA agents/ingest-agent/importers/ccld.jsM agents/ingest-agent/importers/cms-care-compare.jsM agents/ingest-agent/importers/hrsa.jsA agents/ingest-agent/importers/osm-smallbiz.jsA agents/ingest-agent/server.jsA agents/preview-agent/server.jsM agents/shared/compliance.jsM agents/shared/db.jsM agents/shared/package.jsonA agents/web-agent/app/account/claim/page.tsxA agents/web-agent/app/account/page.tsxA agents/web-agent/app/community/page.tsxA agents/web-agent/app/doctors/[slug]/page.tsxA agents/web-agent/app/globals.cssA agents/web-agent/app/layout.tsxA agents/web-agent/app/mockups/page.tsxA agents/web-agent/app/orgs/[slug]/page.tsxA agents/web-agent/app/page.tsxA agents/web-agent/app/search/page.tsxA agents/web-agent/middleware.tsA agents/web-agent/next-env.d.tsA agents/web-agent/next.config.jsA agents/web-agent/package-lock.jsonA agents/web-agent/package.jsonA agents/web-agent/tsconfig.jsonA agents/web-agent/tsconfig.tsbuildinfoA db/migrations/002_data_orders.sqlA db/migrations/003_users_auth.sqlA db/migrations/004_reviews.sqlA db/migrations/005_community.sqlA db/migrations/006_front_page_crawl.sqlA docs/phase-0-domain-research.mdA docs/phase-1-codex-findings.mdA docs/phase-1-design.mdM ecosystem.config.jsM package-lock.jsonM package.jsonA scripts/aggregate-ratings.jsM scripts/apply-templates.jsA scripts/audit-mockups.jsA scripts/build-pitch-html.pyA scripts/cache-healthcare-templates.jsA scripts/check_alt_domains.jsA scripts/check_prospect_domains.jsA scripts/doctor-agent-loop.shA scripts/doctor-nightly-v5-check.shA scripts/doctor-progress-tick.shA scripts/enrich-reddit.jsA scripts/generate-mockups-v3.jsA scripts/generate-mockups-v4.jsA scripts/generate-mockups-v5.jsA scripts/generate-mockups.jsA scripts/hawk-local.shA scripts/reverse_geocode_orgs.jsA scripts/run-mockups-overnight.shA scripts/score_prospects.jsA scripts/scrape-emails-llm.jsA scripts/scrape-org-emails.jsA scripts/seed-reviews-from-reddit.jsA scripts/snapshot-mockups.jsA src/enrich/ad_signals.js
Diff
commit 15d88dc762b94beebdab4d7387d74abc4925c5fa
Author: Steve <steve@designerwallcoverings.com>
Date: Wed May 13 08:57:59 2026 -0700
snapshot: 56 file(s) changed, +46 new, ~10 modified
---
CHANGES.md | 15 +
REVIEW-2026-05-04.md | 61 +
agents/api-agent/MOCKUP_VARIANTS.md | 242 +++
agents/api-agent/auth.js | 120 ++
agents/api-agent/data_market.js | 863 ++++++++
agents/api-agent/routes/admin-pitch.js | 129 ++
agents/api-agent/routes/auth.js | 61 +
agents/api-agent/routes/chat.js | 99 +
agents/api-agent/routes/claims.js | 256 +++
agents/api-agent/routes/messages.js | 104 +
agents/api-agent/routes/reviews.js | 211 ++
agents/api-agent/routes/subscriptions.js | 96 +
agents/api-agent/routes/threads.js | 118 ++
agents/api-agent/server.js | 2159 ++++++++++++++++++++-
agents/crawler-agent/crawlers/front-page.js | 236 +++
agents/crawler-agent/server.js | 81 +
agents/dedupe-agent/server.js | 61 +
agents/enrich-agent/server.js | 68 +
agents/ingest-agent/importers/ccld.js | 188 ++
agents/ingest-agent/importers/cms-care-compare.js | 13 +-
agents/ingest-agent/importers/hrsa.js | 4 +-
agents/ingest-agent/importers/osm-smallbiz.js | 390 ++++
agents/ingest-agent/server.js | 86 +
agents/preview-agent/server.js | 81 +
agents/shared/compliance.js | 58 +-
agents/shared/db.js | 46 +-
agents/shared/package.json | 1 +
agents/web-agent/app/account/claim/page.tsx | 319 +++
agents/web-agent/app/account/page.tsx | 118 ++
agents/web-agent/app/community/page.tsx | 38 +
agents/web-agent/app/doctors/[slug]/page.tsx | 93 +
agents/web-agent/app/globals.css | 177 ++
agents/web-agent/app/layout.tsx | 41 +
agents/web-agent/app/mockups/page.tsx | 97 +
agents/web-agent/app/orgs/[slug]/page.tsx | 58 +
agents/web-agent/app/page.tsx | 90 +
agents/web-agent/app/search/page.tsx | 56 +
agents/web-agent/middleware.ts | 23 +
agents/web-agent/next-env.d.ts | 5 +
agents/web-agent/next.config.js | 12 +
agents/web-agent/package-lock.json | 502 +++++
agents/web-agent/package.json | 24 +
agents/web-agent/tsconfig.json | 21 +
agents/web-agent/tsconfig.tsbuildinfo | 1 +
db/migrations/002_data_orders.sql | 57 +
db/migrations/003_users_auth.sql | 104 +
db/migrations/004_reviews.sql | 103 +
db/migrations/005_community.sql | 103 +
db/migrations/006_front_page_crawl.sql | 25 +
docs/phase-0-domain-research.md | 53 +
docs/phase-1-codex-findings.md | 36 +
docs/phase-1-design.md | 235 +++
ecosystem.config.js | 70 +-
package-lock.json | 329 ++++
package.json | 12 +
scripts/aggregate-ratings.js | 71 +
scripts/apply-templates.js | 64 +
scripts/audit-mockups.js | 164 ++
scripts/build-pitch-html.py | 179 ++
scripts/cache-healthcare-templates.js | 89 +
scripts/check_alt_domains.js | 204 ++
scripts/check_prospect_domains.js | 157 ++
scripts/doctor-agent-loop.sh | 141 ++
scripts/doctor-nightly-v5-check.sh | 111 ++
scripts/doctor-progress-tick.sh | 151 ++
scripts/enrich-reddit.js | 156 ++
scripts/generate-mockups-v3.js | 305 +++
scripts/generate-mockups-v4.js | 317 +++
scripts/generate-mockups-v5.js | 717 +++++++
scripts/generate-mockups.js | 282 +++
scripts/hawk-local.sh | 43 +
scripts/reverse_geocode_orgs.js | 90 +
scripts/run-mockups-overnight.sh | 81 +
scripts/score_prospects.js | 118 ++
scripts/scrape-emails-llm.js | 301 +++
scripts/scrape-org-emails.js | 176 ++
scripts/seed-reviews-from-reddit.js | 78 +
scripts/snapshot-mockups.js | 50 +
src/enrich/ad_signals.js | 123 ++
79 files changed, 12662 insertions(+), 155 deletions(-)
diff --git a/CHANGES.md b/CHANGES.md
new file mode 100644
index 0000000..70239a6
--- /dev/null
+++ b/CHANGES.md
@@ -0,0 +1,15 @@
+## Applied
+- None — no P0/P1 review item matched the permitted safe-fix categories without changing behavior, schema, auth policy, or operational semantics.
+
+## Deferred (needs Steve)
+- P0 — agents/api-agent/server.js:18 — no auth on mutating/control endpoints — deferred because adding shared auth and Origin policy is not one of the allowed safe fixes and needs owner decisions for credentials and exposure model.
+- P0 — agents/ingest-agent/server.js:47 — no auth on importer control endpoint — deferred for the same auth policy reason.
+- P0 — agents/ingest-agent/server.js:55 — request body can override child-process env — deferred because replacing env override behavior with an allowlist changes operator workflow and is not in the allowed safe-fix list.
+- P0 — agents/crawler-agent/server.js:48 — request body can override child-process env — deferred for the same operator-workflow reason.
+- P0 — agents/api-agent/server.js:166 — API references organization columns absent from checked-in schema — deferred because this requires schema/migration work, which is explicitly out of scope.
+- P1 — scripts/run-all-night.sh:29 — failed stages continue and final output still says complete — deferred because fail-fast/final-status behavior is operational policy, not one of the allowed safe fixes.
+- P1 — agents/ingest-agent/importers/npi.js:189 — NPI reruns can insert duplicate locations and phones — deferred because the recommended fix requires database constraints/migrations, explicitly out of scope.
+- P1 — agents/ingest-agent/importers/npi.js:346 — row-level DB failures are counted as skipped and job may finish done — deferred because failure-threshold policy is not in the allowed safe-fix list.
+- P1 — scripts/dedupe.js:80 — org merge updates/deletes multiple tables without a transaction — deferred because it is not in the allowed safe-fix list and affects data repair behavior.
+- P1 — agents/api-agent/server.js:727 — imported website values become clickable hrefs without protocol allowlisting — deferred because changing rendered/exported public-facing HTML behavior is outside the safe-fix list and public-facing copy/HTML is out of scope.
+- P1 — agents/shared/compliance.js:58 — rate limiter is in-memory and non-atomic per process — deferred because shared queue/lock design changes cross-process behavior and is not one of the allowed safe fixes.
diff --git a/REVIEW-2026-05-04.md b/REVIEW-2026-05-04.md
new file mode 100644
index 0000000..9500a31
--- /dev/null
+++ b/REVIEW-2026-05-04.md
@@ -0,0 +1,61 @@
+# professional-directory (doctor) — overnight debate-team review (2026-05-04)
+
+Code-reviewer + architect-reviewer spawned in parallel. **6 patches applied locally** (3 P0 security + 2 P1 + 1 hardening). 7 deferred items + architecture roadmap captured below.
+
+## P0 / P1 patches APPLIED (local — NOT deployed)
+
+| # | File:line | Severity | Issue | Fix |
+|---|---|---|---|---|
+| 1 | `agents/api-agent/server.js:392` | **P0** | `/outreach.csv` exported bulk PII (NPI, license, address, phone, emails) with no auth | Added `requireRole('admin')` |
+| 2 | `agents/api-agent/server.js:566` | **P0** | `/export.csv` exported full professionals/orgs tables unauth'd (NPI, license, school) | Added `requireRole('admin')` |
+| 3 | `agents/api-agent/server.js:159` | **P0** | `/professionals/:npi` did `SELECT p.*` — leaked `opted_out_at`, future internal columns | Enumerated public allow-list (25 columns); excludes `opted_out`, `opted_out_at` |
+| 4 | `agents/crawler-agent/server.js:41` | **P0** | `/run/:id` accepted `req.body.env` and merged into spawned child env — unauth'd env injection | Dropped env passthrough + added `Bearer $CRAWLER_RUN_TOKEN` check (503 until token set) |
+| 5 | `agents/ingest-agent/server.js:47` | **P0** | Same env-injection bug on `/run/:importer` | Same fix: dropped env merge + `Bearer $INGEST_RUN_TOKEN` |
+| 6 | `agents/api-agent/server.js:591` | **P1** | `/opt-out` unauthenticated — anyone could mass-opt-out the entire directory by POSTing IDs | Added `requireRole('admin')` (interim — proper signed-token-emailed-to-practitioner flow needed for self-service opt-out) |
+| 7 | `agents/api-agent/server.js:610` | **P1** | 500 error handler returned `err.message` to client (leaks PG table/column/constraint names) | Returns generic `internal_server_error`; full error in server log; `detail` only in non-prod |
+
+`node --check` passes on all 3 modified files.
+
+## Steve action items (4 — surface in email)
+
+1. **Mint `CRAWLER_RUN_TOKEN` + `INGEST_RUN_TOKEN`** via `secrets` skill, route to pm2 env for pd-crawler + pd-ingest. Until set, `/run/:id` returns 503. (Anything currently calling these endpoints — pd-hawk launchd, manual triggers — needs to send `Authorization: Bearer $TOKEN`.)
+2. **`/preview/:orgId` page (server.js:829) hardcodes "5★ Average patient rating · 90s Years of trust"** next to real org names. Per your lawyer-directory-compliance memory (Rule 7.x — fabricated stats next to real names), this is a legal risk for the doctor vertical too. Either label as "example copy", remove entirely from the public preview, or move to admin-only pitch view.
+3. **Claim verify link uses `req.protocol + req.get('host')`** at `routes/claims.js:104` (open redirect via spoofed Host). Switch to `PUBLIC_BASE_URL` env var pattern that `data_market.js` already uses.
+4. **No helmet/CSP** anywhere in api-agent. `npm install helmet` + `app.use(helmet())` before middleware. Touches package.json so deferred for your review.
+
+## P2 deferred (track in REVIEW)
+
+- `auth.js:47` — `deserializeUser` does `SELECT *` from users; if `hashed_password`/`mfa_secret` columns get added, they'll silently appear on `req.user`. Enumerate columns explicitly.
+- `server.js:434` — `/organizations/:id/emails` requires login but not admin role; any authed user can pull all scraped outreach emails for an org.
+- `routes/claims.js:138` — claim verify breaks on fresh session (link clicked in incognito). Move `GET /:token` outside the auth guard; use the token itself as proof.
+- `data_market.js:505` — Stripe error message included in redirect URL (truncated to 160 chars but can still leak card detail / customer ID). Map to generic "payment configuration error".
+- `server.js:144,260` — `LIMIT ${limit} OFFSET ${offset}` are string-interpolated. Currently safe because of `Math.min(Number(...), 500)` validation but one refactor away from regression. Use `$N` params.
+
+## Architecture roadmap (architect-reviewer findings)
+
+**Architectural Impact: MEDIUM** — bones are sound (worker/API split, shared DB module, idempotent migrations, public-edge isolation via pd-preview). 5 specific drifts accumulating cost.
+
+### 1. Collapse 4 worker agents → 1 `pd-worker`
+`crawler-agent/server.js`, `dedupe-agent/server.js`, `enrich-agent/server.js`, `ingest-agent/server.js` are structurally identical (4 near-duplicate 50-line Express servers each spawning child scripts from a sibling dir). Crawler has *one* crawler, dedupe has *one* job. The 7-agent fan-out is theatre at this scale.
+
+**Action:** merge into `agents/worker-agent/server.js` with `POST /run/:kind/:id` dispatching to `crawlers/`/`enrichers/`/`matchers/`/`importers/` subdirs. Drops 4 pm2 entries → 1, simplifies hawk-local.sh, eliminates 3 ports. Re-split only when a worker actually needs different memory/concurrency.
+
+### 2. Extract `directory-core` shared package
+Lawyer-directory has TS `src/server/{admin,auth,billing,community,data_market,leads,...}.ts`. Doctor has near-identical concepts in JS `agents/api-agent/routes/{auth,claims,subscriptions,reviews,threads,messages,admin-pitch}.js` + `data_market.js`. Two implementations, one TS one JS — diverge on every bug fix. Already 4 verticals (doctor/lawyer/animals/restaurant) per MEMORY.
+
+**Action:** extract `~/Projects/directory-core/` npm workspace exporting (a) migration runner, (b) shared route modules (claims, reviews, threads, data_market, Stripe webhook handler, Google OAuth), (c) apply-templates engine. Each vertical = `directory-core` + `verticals/<name>/` config (DB name, specialty list, mockup hooks, scoring). Settle on TS for the core; JS adapters via `.d.ts` shims. **Without this, every compliance fix lands in 1 of 4 codebases.**
+
+### 3. Break up `agents/api-agent/server.js` (2,310 lines)
+Already mounts 9 sub-routers but still inlines `/professionals`, `/organizations`, `/search`, `/website-data/:npi`, `/export.csv`, `/opt-out`, `/preview/:id`, `getCounts()`, `renderDashboard()` (300+ lines of inline HTML).
+
+**Action:** move into `routes/professionals.js`, `routes/organizations.js`, `routes/search.js`, `routes/export.js`, `routes/preview.js`, `admin/dashboard.js`. `server.js` ≤ 200 lines. Precondition for #2.
+
+### 4. Smoke-test harness — anything beats zero
+Zero tests. The defensive `throw new Error('stripeWebhookHandler not exported')` at `server.js:42` is a runtime assertion compensating for missing import test.
+
+**Action:** `tests/smoke/` with (a) migrate.test.js boots temp PG db + asserts tables, (b) api-routes.test.js boots pd-api against temp + hits every GET, (c) stripe-webhook.test.js POSTs signed fixture + asserts 200. Wire to `npm test` + `hawk-local.sh`. ~200 LOC prevents the "all 3 sites served wrong HTML" class of incident.
+
+### 5. Codify drvouch deploy story
+No Kamatera deploy script, no Dockerfile, no nginx config, no doc in `docs/` mentioning drvouch. `ecosystem.config.js` is local-only (loopback binds, hardcoded `/Users/stevestudio2`). MEMORY lists drvouch but nothing in the repo points there.
+
+**Action:** `docs/deploy-kamatera.md` capturing target topology (which agents on Kamatera vs Mac2, nginx routing to drvouch, secrets fan-out, launchd→systemd translation). `deploy/kamatera-ecosystem.config.js` mirrors local but reads `process.env.PORT_*` + `DATABASE_URL`. Until exists, drvouch is a verbal contract.
diff --git a/agents/api-agent/MOCKUP_VARIANTS.md b/agents/api-agent/MOCKUP_VARIANTS.md
new file mode 100644
index 0000000..af67925
--- /dev/null
+++ b/agents/api-agent/MOCKUP_VARIANTS.md
@@ -0,0 +1,242 @@
+# Mockup Variants — Per-Org Website Pitch System
+
+Process doc for the 4-direction mockup feature in `server.js`. Backfills the
+process markdown that should have been written before the multi-step build
+landed (per Steve's `feedback_suggest_process_md.md` rule).
+
+---
+
+## Why this exists
+
+Sales pitch flow: pd-api scores small businesses lacking websites, then
+generates a "here's what your site could look like" mockup that's emailed to
+the prospect. Until 2026-05-01 the system rendered **one** template per org —
+every prospect saw the same design language with only their content swapped
+in. Steve's correction: *"Must be unique mockup sites for all. Do not do the
+same site over and over."*
+
+Resolution: **4 visually-distinct templates × per-org parameterization** so the
+3-direction pitch grid an org sees is unique to them across the catalog.
+
+---
+
+## Templates
+
+| ID | Name | Source | Visual signature | Palette count |
+|----|----------------|------------------|------------------------------------------------------------|----|
+| **A** | Modern Clinical | hand-built | Sans-serif (Inter), generous whitespace, 3 hero shapes (split / stacked / banner-stripe), Stripe/Notion-clean | 5 |
+| **B** | Warm Community | hand-built | Iowan Old Style serif headlines + sans body, earth tones, hero card with circular badge punching out, editorial family-practice | 5 |
+| **C** | Premium Wellness | hand-built | Didot serif XXL, dark luxury background, vertical accent line, 2-col service rhythm, concierge/spa aesthetic | 5 |
+| **D** | Wellness App | **21st.dev — PulseFit hero** (`mcp__magic__21st_magic_component_inspiration`, query: "healthcare clinic landing hero") — ported from React+framer-motion to server-rendered HTML+CSS keyframe | Icy/soft gradient wash, centered Inter 80px headline, pill buttons, social-proof avatar row, **CSS-keyframe auto-scrolling service-tile carousel** with edge fades | 5 |
+| **E** | Editorial-Brutalist | **21st.dev — Hero 03 by aliimam.in** (`mcp__magic__21st_magic_component_inspiration`, query: "brutalist landing hero", similarity 0.105 — only orthogonal direction the re-queries surfaced) — adapted to healthcare with per-type 3-line ALL-CAPS statements, vitals-red heart icon, HIPAA side rail | Helvetica Neue 300 / clamp(54-160px) / ALL-CAPS three-line statement, IBM Plex Mono italic flanking-meta paragraphs, vertical rotated right-edge badge, hairline `<Separator>` between sections, dotted x-ray-cyan grid bg, NO rounded chrome, NO gradients | 5 |
+
+**Total base looks:** 5 templates × 5 palettes = 25 (+ 3 hero shapes for A → 35
+combinations within A alone). The 5-up grid (A+B+C+D+E) gives 5⁵ = **3,125
+unique 5-tuples** across the catalog — two orgs ever seeing identical 5-design
+pitches is effectively impossible across 5K target orgs.
+
+---
+
+## Palette inventories
+
+### Template A — Modern Clinical (`renderPreviewModernClinical`)
+| Palette | bg | ink | accent | soft |
+|---------|-----|-----|--------|------|
+| teal | `#F7F9FB` | `#0E1726` | `#0F766E` | `#E6F2F1` |
+| azure | `#FAFAFC` | `#1F2937` | `#2563EB` | `#E8EFFE` |
+| sky | `#F8FAFC` | `#0B1220` | `#0EA5E9` | `#E0F2FE` |
+| forest | `#F4F8F4` | `#14241D` | `#15803D` | `#DCFCE7` |
+| amber | `#FAF8F4` | `#221A0F` | `#B45309` | `#FDE9C9` |
+
+### Template B — Warm Community (`renderPreviewWarmCommunity`)
+| Palette | bg | ink | accent | soft | card |
+|---------|-----|-----|--------|------|------|
+| honey | `#FBF6EF` | `#2A1F12` | `#A16207` | `#F2E2C5` | `#FFFAF1` |
+| terracotta | `#F7F2EA` | `#1F1B16` | `#9F2F00` | `#F1D3C2` | `#FFF6EE` |
+| olive | `#F4F6F0` | `#1B2014` | `#4D7C0F` | `#DCEBC2` | `#FBFCEF` |
+| rose | `#FAF2F4` | `#241419` | `#9D174D` | `#F4D3DD` | `#FFF7F9` |
+| lavender | `#F0EFF7` | `#191625` | `#5B21B6` | `#DCD6F5` | `#F8F6FF` |
+
+### Template C — Premium Wellness (`renderPreviewPremiumWellness`)
+| Palette | bg | ink | accent | soft | card |
+|---------|-----|-----|--------|------|------|
+| gold | `#0E0F12` | `#F1ECE0` | `#C9A567` | `#1B1D22` | `#16181D` |
+| sage-noir | `#0C1116` | `#E8E4D8` | `#9CA88F` | `#15191F` | `#11161B` |
+| copper | `#10110D` | `#EDE8DA` | `#B98C5A` | `#1A1B16` | `#15161F` |
+| rose-quartz | `#0F0E10` | `#EFEAE0` | `#D4B5C6` | `#1A1820` | `#161520` |
+| eucalyptus | `#0A0F11` | `#E2E7DD` | `#7BAA8B` | `#121819` | `#0F1517` |
+
+### Template D — Wellness App (`renderPreviewWellnessApp`)
+| Palette | gradient top | gradient mid | accent | soft |
+|---------|--------------|--------------|--------|------|
+| icy-blue | `#E8F0FF` | `#F5F9FF` | `#1E3A8A` | `#DBEAFE` |
+| mint | `#E8FFF1` | `#F2FFF7` | `#065F46` | `#D1FAE5` |
+| peach | `#FFEFE6` | `#FFF7F2` | `#9A3412` | `#FED7AA` |
+| lavender | `#F1E8FF` | `#F7F2FF` | `#5B21B6` | `#E9D5FF` |
+| soft-amber | `#FFF8E1` | `#FFFCF0` | `#92400E` | `#FEE9B0` |
+
+### Template E — Editorial-Brutalist (`renderPreviewEditorialBrutalist`)
+Mood derivation per Paper.design: each palette = a physical scene from a
+clinical-paper world. Vitals-red and x-ray cyan recur as the brutalist accents.
+
+| Palette | bg (paper) | ink | meta | accent (vitals) | cool (cyan/dot) | mood |
+|---------|------------|-----|------|------------|----------|--------|
+| bone | `#F4F1EA` | `#0A0A0A` | `#5C5C5C` | `#D63F2A` | `#7AB8C4` | chart paper at dawn |
+| chart | `#F1F4F0` | `#13241D` | `#506257` | `#1B5E20` | `#A8C8B5` | botanical |
+| clinical | `#F8F8F4` | `#0F1115` | `#5A5A5A` | `#1A4D8C` | `#B8CCE0` | mineral |
+| dawn | `#F2EEE3` | `#1A0F0A` | `#6B4F35` | `#C2410C` | `#FBE3B3` | candlelit |
+| radiograph | `#1A1B1F` | `#E8E8E8` | `#888888` | `#FF6B5B` | `#65BAC8` | nocturnal x-ray (only dark variant) |
+
+**Per-type headline mapping** (`BRUTALIST_HEADLINES` const) — each org type
+gets a unique 3-line ALL-CAPS statement so the hero doesn't read as generic.
+Examples: hospital → `WORLD / CLASS / CARE`, dental_office → `QUIET / ROOMS /
+HONEST PRICES`, hospice → `COMFORT / DIGNITY / PRESENCE`. Mid-line gets a
+vitals-red heart SVG inserted as a typographic glyph between the first and
+remaining words.
+
+**Per-type side-rail badge** (`BRUTALIST_BADGES` const) — vertical rotated text
+pinned to the right viewport edge, e.g., hospital → `EMERGENCY READY`,
+dental_office → `HSA / FSA`, hospice → `24/7 ON-CALL`.
+
+---
+
+## Parameterization (per-org rotation)
+
+```js
+function pickSeed(str, mod) {
+ let h = 0;
+ for (let i = 0; i < String(str).length; i++) h = ((h << 5) - h) + str.charCodeAt(i) | 0;
+ return Math.abs(h) % mod;
+}
+```
+
+For each template, palette is picked via `pickSeed(o.id + variantTag, palettes.length)`.
+Variant tag is `'A' | 'B' | 'C' | 'D'`. Within template A, hero shape uses a
+second seed: `pickSeed(o.id + 'A-hero', 3)`. Same orgId → same palettes across
+reloads (deterministic), so the 4-design pitch is reproducible.
+
+**Why deterministic instead of random:** prospects who reload the email link a
+day later must see the same 4 designs they saw before. Random rotation would
+break consistency between the email and the live pitch page.
+
+---
+
+## Routes
+
+| Route | Renders | Notes |
+|-------|---------|-------|
+| `GET /preview/:orgId` | Original single template (`renderPreview`) | Pre-existing, unchanged. Kept for backward compat with any link still floating around. |
+| `GET /preview/:orgId/all` | 4-up iframe grid (`renderPreviewVariantIndex`) | **Default for new pitch flow.** Responsive: 4-up desktop, 2-up tablet, 1-up phone. |
+| `GET /preview/:orgId/a` (or `/modern`) | Template A | |
+| `GET /preview/:orgId/b` (or `/community`) | Template B | |
+| `GET /preview/:orgId/c` (or `/premium`) | Template C | |
+| `GET /preview/:orgId/d` (or `/wellness`) | Template D | |
+| `GET /preview/:orgId/e` (or `/editorial` / `/brutalist`) | Template E | |
+| Other variant tokens | 404 | Validated in handler — Express 5 dropped inline regex like `:variant(a\|b\|c)` |
+
+---
+
+## Where each thing lives in `server.js`
+
+| Function | Role |
+|----------|------|
+| `pickSeed(str, mod)` | Hash → palette/shape index |
+| `renderPreviewModernClinical(o, pros)` | Template A |
+| `renderPreviewWarmCommunity(o, pros)` | Template B |
+| `renderPreviewPremiumWellness(o, pros)` | Template C |
+| `renderPreviewWellnessApp(o, pros)` | Template D — adapted from 21st.dev PulseFit hero |
+| `renderPreviewEditorialBrutalist(o, pros)` | Template E — adapted from 21st.dev Hero 03 (aliimam.in) |
+| `renderPreviewVariantIndex(o)` | 5-up grid index page (responsive: 5-up→3-up→2-up→1-up) |
+| `splitNameAccent(name)` | Helper — safe lead/tail split for B+C `<em>` accent (single-word safe) |
+| `ctaHref(phoneTel)` | Helper — phone tel: or fall back to `#contact` anchor (no dead `#`) |
+
+Pitch modal action row (in `renderDashboard`'s inline JS) has direct links to
+`/preview/:id/{all,a,b,c,d}` plus the legacy bespoke mockup + the prospect's
+current site.
+
+---
+
+## How to add a 5th template
+
+1. **Source.** Either hand-build OR fetch from a design-system MCP:
+ - `mcp__magic__21st_magic_component_inspiration` — query needs to be specific
+ ("brutalist editorial hero", "monospace dev-tool landing", "magazine
+ drop-cap layout"); generic queries return same 3 results.
+ - `mcp__figma__get_design_context` — pass a Figma node URL.
+2. **Convert.** If sourced from React+animation lib (framer-motion, etc.),
+ port to **server-rendered HTML+CSS** — no JS runtime. Keyframe animations
+ are fine; React/framer-motion hydration is not.
+3. **No stock images.** Per `feedback_no_stock_images.md`. Use
+ abstract gradient tiles, color blocks, or CSS-only illustrations. The
+ pitch goes to ~5K orgs; stock photos would be identical across all of
+ them and defeat the uniqueness goal even if the rule didn't exist.
+4. **Add to rotation:**
+ - Define palette inventory (5 entries) at the top of the renderer.
+ - Use `pickSeed(o.id + 'E', palettes.length)` for the palette pick.
+ - Export `renderPreviewYourTemplate(o, pros)`.
+5. **Wire routes:** add token (e.g. `'e'`) to `VALID` Set in the
+ `/preview/:orgId/:variant` handler, add to the renderer ternary chain.
+6. **Update `/all` index** (`renderPreviewVariantIndex`): change grid to
+ `repeat(5, 1fr)`, add the new column.
+7. **Update pitch modal** (in `renderDashboard`): add the new link to the
+ action row; update the email body copy from "four concepts" → "five
+ concepts" + the new variant name.
+8. **Update this doc** — palette table, total-look math, route table.
+
+---
+
+## Deploy + restart notes
+
+- pd-api runs as **pm2 `pd-api`** on `127.0.0.1:9874` (loopback only — Mac2)
+- Restart: `pm2 restart pd-api` (no SIGINT issues; clean reload).
+- pd-api is **not in `cncp-config.json domains[]`** — restarts don't fall
+ under the `feedback_pm2_deploy_explicit_auth.md` rule. "go"/"y" is
+ sufficient authorization for restarting it.
+- Smoke test after any template change:
+ ```
+ for v in a b c d all; do curl -s -o /dev/null -w "/$v %{http_code}\n" http://127.0.0.1:9874/preview/19/$v; done
+ ```
+- Validate variant fingerprints across multiple orgs (palette rotation):
+ ```
+ for orgid in 19 20 21 100; do
+ curl -s http://127.0.0.1:9874/preview/$orgid/a | grep -oE "palette=[a-z-]+" | head -1
+ done
+ ```
+
+---
+
+## Resolved (this session)
+
+- ✅ **Single-word org names** broke templates B + C (empty leading `<em>` span).
+ Fixed via `splitNameAccent(name)` helper — falls back to `<em>${tail}</em>`
+ alone when `lead` is empty.
+- ✅ **Null-phone CTA buttons** rendered `href="#"` (dead anchor) on orgs
+ without phone. Fixed via `ctaHref(phoneTel)` helper — degrades to
+ `#contact` in-page anchor so the CTA scrolls somewhere meaningful.
+- ✅ **Template C contact section missing `id="contact"`** — added so the new
+ ctaHref fallback actually has a target.
+- ✅ **Pitch modal link clutter** — 4 per-variant links collapsed into a
+ `<details>` disclosure (`individual variants ▾`), keeping `★ /all` as the
+ primary action.
+
+## Known follow-ups
+- **Pre-generate static HTML** for top-N prospects so email-link clicks
+ bypass pd-api entirely. ~5K orgs × 4 variants × ~6KB = ~120 MB; cache
+ invalidation needs a signal (e.g., prospect data changed → re-render).
+- **More template diversity** — re-query 21st.dev with explicit terms
+ (brutalist / editorial / monospace) to widen the rotation. Subagent
+ flagged the first query returned only 3 designs in the
+ PulseFit-adjacent space.
+- **Pull a Figma source.** The Figma MCP wasn't used yet; one direction
+ pulled directly from a Figma file would round out the 21st.dev port and
+ honor the literal "use 21stdev components and figma" ask.
+- **Email body copy** still says "I'll set up the real domain and put it
+ live" — pre-existing language not from this round, but worth updating
+ if pitch flow ever becomes automated (Steve sets up domains, not the
+ agent).
+
+---
+
+*Last updated: 2026-05-01 — initial backfill, then second pass after rigorous
+four-horsemen test added template E (editorial-brutalist), patched P1
+single-word + null-phone fixes, collapsed modal link clutter into disclosure.*
diff --git a/agents/api-agent/auth.js b/agents/api-agent/auth.js
new file mode 100644
index 0000000..25ac1f2
--- /dev/null
+++ b/agents/api-agent/auth.js
@@ -0,0 +1,120 @@
+/**
+ * Auth helpers for pd-api.
+ *
+ * - passport-google-oauth20 strategy (idempotent upsert into users on login)
+ * - serialize/deserialize via users.id
+ * - requireRole(...roles) middleware factory
+ * - requireTier(tier) middleware factory (admin always passes)
+ * - loopbackSyntheticAdmin middleware — gives Steve admin on 127.0.0.1
+ *
+ * No clinical/HIPAA-protected data flows through these routes (Phase 1
+ * non-clinical scope per ~/.claude/plans/we-are-selling-websites-...md).
+ */
+const passport = require('passport');
+const GoogleStrategy = require('passport-google-oauth20').Strategy;
+const { query } = require('../shared/db');
+
+const CALLBACK_URL = process.env.GOOGLE_OAUTH_CALLBACK || '/auth/google/callback';
+const CLIENT_ID = process.env.GOOGLE_OAUTH_CLIENT_ID || '';
+const CLIENT_SECRET = process.env.GOOGLE_OAUTH_CLIENT_SECRET || '';
+
+if (CLIENT_ID && CLIENT_SECRET) {
+ passport.use(new GoogleStrategy({
+ clientID: CLIENT_ID,
+ clientSecret: CLIENT_SECRET,
+ callbackURL: CALLBACK_URL,
+ }, async (_accessToken, _refreshToken, profile, done) => {
+ try {
+ const email = (profile.emails?.[0]?.value || '').toLowerCase();
+ if (!email) return done(new Error('Google profile has no email'));
+ const r = await query(`
+ INSERT INTO users (email, google_sub, display_name, avatar_url, email_verified_at, last_login_at)
+ VALUES ($1, $2, $3, $4, now(), now())
+ ON CONFLICT (email) DO UPDATE SET
+ google_sub = COALESCE(users.google_sub, EXCLUDED.google_sub),
+ display_name = COALESCE(users.display_name, EXCLUDED.display_name),
+ avatar_url = COALESCE(users.avatar_url, EXCLUDED.avatar_url),
+ last_login_at = now()
+ RETURNING *
+ `, [email, profile.id, profile.displayName || null, profile.photos?.[0]?.value || null]);
+ done(null, r.rows[0]);
+ } catch (e) { done(e); }
+ }));
+
+ passport.serializeUser((u, cb) => cb(null, u.id));
+ passport.deserializeUser(async (id, cb) => {
+ try {
+ const r = await query('SELECT * FROM users WHERE id = $1 AND deleted_at IS NULL', [id]);
+ cb(null, r.rows[0] || null);
+ } catch (e) { cb(e); }
+ });
+}
+
+function requireRole(...roles) {
+ return (req, res, next) => {
+ if (!req.user) return res.status(401).json({ error: 'auth required' });
+ if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'forbidden' });
+ next();
+ };
+}
+
+function requireTier(tier) {
+ return (req, res, next) => {
+ if (!req.user) return res.status(401).json({ error: 'auth required' });
+ if (req.user.role === 'admin') return next();
+ if (req.user.tier !== tier) return res.status(402).json({ error: 'paid tier required', upgrade: '/subscriptions/checkout' });
+ next();
+ };
+}
+
+/**
+ * Loopback synthetic-admin: when ADMIN_LOOPBACK_TRUST=1 (set in pm2 ecosystem)
+ * and the request originates from 127.0.0.1, inject a synthetic admin user.
+ * Lets Steve drive the existing dashboard at http://127.0.0.1:9874 without
+ * going through Google OAuth. Remote callers (via tunnel or pd-preview) get
+ * NO bypass — they go through the standard passport flow.
+ */
+// Resolve the admin user_id ONCE at boot from the steve@designerwallcoverings.com
+// row that we seed on Phase 1 install. Falls back to 1 if lookup fails (the seed
+// row should always be id=1 as the first INSERT into users).
+let _adminUserId = Number(process.env.ADMIN_USER_ID) || null;
+async function _ensureAdminUserId() {
+ if (_adminUserId) return _adminUserId;
+ try {
+ const r = await query("SELECT id FROM users WHERE email = $1", ['steve@designerwallcoverings.com']);
+ _adminUserId = r.rows[0]?.id || 1;
+ } catch (_) { _adminUserId = 1; }
+ return _adminUserId;
+}
+
+async function loopbackSyntheticAdmin(req, _res, next) {
+ if (process.env.ADMIN_LOOPBACK_TRUST !== '1') return next();
+ if (req.user) return next();
+ // SECURITY: codex found that req.ip honors X-Forwarded-For when trust-proxy is on,
+ // letting any tunneled caller spoof 127.0.0.1. We MUST check the raw socket.
+ const sockIp = req.socket?.remoteAddress || '';
+ const isLoopback = sockIp === '127.0.0.1' || sockIp === '::1' || sockIp === '::ffff:127.0.0.1';
+ if (!isLoopback) return next();
+ const id = await _ensureAdminUserId();
+ req.user = {
+ id,
+ email: 'steve@designerwallcoverings.com',
+ role: 'admin',
+ tier: 'paid',
+ display_name: 'Steve (loopback)',
+ _synthetic: true,
+ };
+ next();
+}
+
+function authConfigured() {
+ return Boolean(CLIENT_ID && CLIENT_SECRET);
+}
+
+module.exports = {
+ passport,
+ requireRole,
+ requireTier,
+ loopbackSyntheticAdmin,
+ authConfigured,
+};
diff --git a/agents/api-agent/data_market.js b/agents/api-agent/data_market.js
new file mode 100644
index 0000000..10c8b82
--- /dev/null
+++ b/agents/api-agent/data_market.js
@@ -0,0 +1,863 @@
+/**
+ * Doctor data marketplace — sell lists of LA County licensed practitioners
+ * filtered by specialty and/or city. Mirrors Counsel & Bar's data_market
+ * pattern (single data_orders table, download_token = auth, server-rendered
+ * HTML with editorial brand) for a different buyer type (medical recruiters,
+ * pharma reps, B2B sales into healthcare).
+ *
+ * GET /data — public catalog: pick specialties + cities
+ * POST /data/checkout — creates data_orders row + Stripe Checkout
+ * GET /data/thanks?session_id=… — Stripe redirect, marks paid, gives DL link
+ * GET /data/download/:token — gated CSV (live one_time + active sub)
+ * GET /admin/data-orders — admin queue (basic-auth same as pd-api admin)
+ * POST /webhooks/stripe-data — Stripe webhook (raw body, sig-verified)
+ *
+ * Pricing model (same shape as lawyer marketplace):
+ * one_time: $49 base + $0.04 per doctor, capped at $299 / specialty
+ * multi-pick (≥3 selections across specialties+cities) → 30% off
+ * subscription: $99/month, all LA County, monthly refresh
+ */
+
+const express = require('express');
+const crypto = require('node:crypto');
+const Stripe = require('stripe');
+const { query } = require('../shared/db');
+
+const STRIPE_KEY = process.env.STRIPE_SECRET_KEY || '';
+const stripe = STRIPE_KEY.startsWith('sk_') ? new Stripe(STRIPE_KEY) : null;
+
+// Codex P1 #2 fix: build Stripe success/cancel URLs from a configured base, not
+// from request headers. Reading req.headers.host / x-forwarded-host lets a
+// hostile Host header redirect the buyer (with their session_id) to an
+// attacker-controlled domain. Default to loopback for dev; override in env.
+const PUBLIC_BASE_URL = (process.env.BUY_PUBLIC_BASE_URL
+ || process.env.PUBLIC_BASE_URL
+ || `http://127.0.0.1:${process.env.PORT_API || 9874}`).replace(/\/+$/, '');
+
+const SUBSCRIPTION_CENTS_MONTHLY = 9900;
+const ONETIME_BASE_CENTS = 4900;
+const ONETIME_PER_DOCTOR_CENTS = 4; // $0.04 — bulk-friendly
+const ONETIME_CAP_PER_PICK_CENTS = 29900; // $299
+const BUNDLE_DISCOUNT_PCT = 30; // 3+ picks total
+const ONETIME_FLOOR_CENTS = 1900; // $19
+
+const router = express.Router();
+router.use(express.urlencoded({ extended: false }));
+
+// ─── escape + shell ─────────────────────────────────────────────────────────
+
+function esc(s) {
+ if (s === null || s === undefined) return '';
+ return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+}
+
+function shellHead(title, opts = {}) {
+ return `<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(title)} · Practitioner Index</title>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;1,300;1,400&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
+<style>
+:root{--noir:#0a0a0c;--noir-rise:#131316;--noir-deep:#050507;--rule:#2a2724;--rule-faint:#1a1815;--ink:#f4f1ea;--ink-soft:#d8d2c5;--ink-mute:#8b857a;--metal:#b89968;--metal-glow:#d4b683;--metal-deep:#8a7044;--good:#34d399;--warn:#d4a04a;--bad:#d87a7a;--serif:"Cormorant Garamond",Georgia,serif;--sans:"Inter",-apple-system,system-ui,sans-serif}
+*{box-sizing:border-box}html{-webkit-font-smoothing:antialiased}
+body{margin:0;background:var(--noir);color:var(--ink);font-family:var(--sans);font-weight:300;font-size:15px;line-height:1.55}
+::selection{background:var(--metal);color:var(--noir)}
+a{color:var(--metal);text-decoration:none}a:hover{color:var(--metal-glow)}
+header.brand{padding:22px 48px;display:flex;align-items:center;gap:18px;justify-content:space-between;border-bottom:1px solid var(--rule);background:var(--noir-deep)}
+header.brand h1{margin:0;font-family:var(--serif);font-size:22px;font-weight:400}
+header.brand h1 a{color:var(--ink)}
+header.brand h1 .accent{color:var(--metal);font-style:italic;padding:0 4px}
+.topnav{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-mute);display:flex;gap:24px}
+.topnav a{color:var(--ink-mute)}.topnav a:hover{color:var(--ink)}
+main{max-width:${opts.wide ? '1240px' : '980px'};margin:0 auto;padding:64px 48px}
+.eyebrow{font-size:10px;letter-spacing:.3em;text-transform:uppercase;color:var(--metal);font-weight:500;margin:0 0 18px}
+h1.display{font-family:var(--serif);font-weight:300;font-size:clamp(40px,6vw,68px);line-height:1.05;letter-spacing:-.02em;margin:0 0 16px;color:var(--ink)}
+h1.display em{font-style:italic;color:var(--metal);font-weight:400}
+.lede{font-size:17px;color:var(--ink-soft);max-width:60ch;margin:0 0 40px;font-weight:300}
+.flash{padding:14px 18px;margin-bottom:24px;border:1px solid var(--rule);font-size:13px;letter-spacing:.06em}
+.flash.error{background:#2a0e0e;color:var(--bad);border-color:#4a1a1a}
+.flash.success{background:#0e2a1a;color:var(--good);border-color:#1a4a30}
+.muted{color:var(--ink-mute);font-size:13px}
+button,input,select{font:inherit;font-family:var(--sans);color:var(--ink);background:var(--noir-rise);border:1px solid var(--rule);padding:13px 16px;transition:border-color 180ms ease}
+input:focus,select:focus{outline:none;border-color:var(--metal);box-shadow:0 0 0 3px rgba(184,153,104,.12)}
+button.btn{background:var(--metal);border-color:var(--metal);color:var(--noir);font-weight:500;font-size:12px;letter-spacing:.18em;text-transform:uppercase;padding:14px 26px;cursor:pointer}
+button.btn:hover{background:var(--metal-glow);border-color:var(--metal-glow)}
+button.btn.ghost{background:transparent;color:var(--metal)}
+button.btn.ghost:hover{background:#1a1611;color:var(--metal-glow)}
+table{width:100%;border-collapse:collapse;font-size:13px}
+th,td{padding:14px 12px;border-bottom:1px solid var(--rule);text-align:left;vertical-align:top}
+th{color:var(--ink-mute);font-weight:500;font-size:10px;text-transform:uppercase;letter-spacing:.18em}
+.pill{display:inline-block;font-size:10px;padding:3px 9px;border:1px solid var(--rule);letter-spacing:.14em;text-transform:uppercase;font-weight:500}
+.pill.pending_payment{background:#2a2010;color:var(--warn);border-color:#4a3a1a}
+.pill.paid{background:#0e2a1a;color:var(--good);border-color:#1a4a30}
+.pill.refunded{background:#2a1410;color:var(--warn);border-color:#4a2520}
+.pill.cancelled,.pill.sub_cancelled{background:var(--noir-rise);color:var(--ink-mute);border-color:var(--rule)}
+</style></head><body>
+<header class="brand">
+ <h1><a href="/">Practitioner <span class="accent">·</span> Index</a></h1>
+ <nav class="topnav">
+ <a href="/">Directory</a>
+ <a href="/data">Data</a>
+ </nav>
+</header>
+<main>`;
+}
+
+const FOOT = `</main>
+<footer style="padding:36px 48px;border-top:1px solid var(--rule);text-align:center;color:var(--ink-mute);font-size:11px;letter-spacing:.16em;text-transform:uppercase">
+ Practitioner Index · LA County licensed clinicians · Sourced from CMS NPPES public registry · HIPAA-permissible directory data only
+</footer>
+</body></html>`;
+
+// ─── pricing math (pure) ────────────────────────────────────────────────────
+
+function computePrice(kind, picksCount, doctorCount) {
+ if (kind === 'subscription') return SUBSCRIPTION_CENTS_MONTHLY;
+ const raw = ONETIME_BASE_CENTS + (doctorCount * ONETIME_PER_DOCTOR_CENTS);
+ const cappedPerPick = Math.min(raw, ONETIME_CAP_PER_PICK_CENTS * Math.max(1, picksCount));
+ const discounted = picksCount >= 3 ? Math.round(cappedPerPick * (1 - BUNDLE_DISCOUNT_PCT / 100)) : cappedPerPick;
+ return Math.max(discounted, ONETIME_FLOOR_CENTS);
+}
+
+function fmtCents(c) {
+ if (c % 100 === 0) return `$${(c / 100).toLocaleString()}`;
+ return `$${(c / 100).toFixed(2)}`;
+}
+
+// ─── catalog data ───────────────────────────────────────────────────────────
+
+async function loadSpecialtyRollup() {
+ // Top primary specialties by doctor count. Cap to a manageable list (~30).
+ // Group COALESCE-cleaned names so we don't show empty rows.
+ const r = await query(`
+ SELECT primary_specialty AS name, COUNT(*)::int AS doctor_count
+ FROM professionals
+ WHERE opted_out=false
+ AND primary_specialty IS NOT NULL
+ AND primary_specialty <> ''
+ GROUP BY primary_specialty
+ HAVING COUNT(*) >= 50
+ ORDER BY doctor_count DESC
+ LIMIT 30
+ `, []);
+ return r.rows;
+}
+
+async function loadCityRollup() {
+ // City rollup from organizations (where doctors practice). Minimum 25 to keep
+ // the picker tight. INITCAP cleans the all-caps NPI city values.
+ const r = await query(`
+ SELECT INITCAP(LOWER(o.city)) AS city,
+ COUNT(DISTINCT pl.professional_id)::int AS doctor_count
+ FROM organizations o
+ JOIN professional_locations pl ON pl.organization_id = o.id
+ JOIN professionals p ON p.id = pl.professional_id AND p.opted_out=false
+ WHERE o.state='CA' AND o.county='Los Angeles'
+ AND o.city IS NOT NULL AND o.city <> ''
+ GROUP BY INITCAP(LOWER(o.city))
+ HAVING COUNT(DISTINCT pl.professional_id) >= 25
+ ORDER BY doctor_count DESC
+ LIMIT 60
+ `, []);
+ return r.rows;
+}
+
+// Doctor count for the buyer's filter combo. Specialties + cities = AND.
+// If no specialties picked → all specialties; same for cities. (At least one
+// dimension must have a pick — POST validates that.)
+async function doctorCountForPicks(specialties, cities) {
+ const params = [];
+ const where = [`p.opted_out=false`];
+ if (specialties.length) {
+ params.push(specialties);
+ where.push(`p.primary_specialty = ANY($${params.length}::text[])`);
+ }
+ if (cities.length) {
+ params.push(cities.map(c => c.toLowerCase()));
+ where.push(`LOWER(o.city) = ANY($${params.length}::text[])`);
+ }
+ const r = await query(`
+ SELECT COUNT(DISTINCT p.id)::text AS n
+ FROM professionals p
+ LEFT JOIN professional_locations pl ON pl.professional_id = p.id
+ LEFT JOIN organizations o ON o.id = pl.organization_id
+ WHERE ${where.join(' AND ')}
+ `, params);
+ return parseInt(r.rows[0]?.n || '0', 10);
+}
+
+// ─── GET /data — public catalog ─────────────────────────────────────────────
+
+router.get('/data', async (req, res) => {
+ const flash = req.query.err
+ ? `<div class="flash error">${esc(String(req.query.err).slice(0, 200))}</div>`
+ : req.query.msg
+ ? `<div class="flash success">${esc(String(req.query.msg).slice(0, 200))}</div>`
+ : '';
+
+ const [specialties, cities, totalDoctors] = await Promise.all([
+ loadSpecialtyRollup(),
+ loadCityRollup(),
+ query(`SELECT COUNT(*)::text AS n FROM professionals WHERE opted_out=false AND npi_number IS NOT NULL`)
+ .then(r => parseInt(r.rows[0].n, 10)),
+ ]);
+
+ const specialtyHtml = specialties.map(s => `
+ <label class="pick">
+ <input type="checkbox" name="specialties" value="${esc(s.name)}" data-doctors="${s.doctor_count}">
+ <span class="pn">${esc(s.name)}</span>
+ <span class="pc">${s.doctor_count.toLocaleString()}</span>
+ </label>`).join('');
+
+ const cityHtml = cities.map(c => `
+ <label class="pick">
+ <input type="checkbox" name="cities" value="${esc(c.city)}" data-doctors="${c.doctor_count}">
+ <span class="pn">${esc(c.city)}</span>
+ <span class="pc">${c.doctor_count.toLocaleString()}</span>
+ </label>`).join('');
+
+ res.send(shellHead('Doctor data lists', { wide: true }) + `
+ <style>
+ .data-grid{display:grid;grid-template-columns:1.4fr 1fr;gap:64px;align-items:start;margin-top:32px}
+ .picker-row{display:grid;grid-template-columns:1fr 1fr;gap:24px}
+ .picker{border:1px solid var(--rule);max-height:420px;overflow:auto}
+ .picker-h{padding:11px 16px;background:var(--noir-rise);border-bottom:1px solid var(--rule);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-mute)}
+ .pick{display:flex;align-items:center;gap:12px;padding:10px 16px;border-bottom:1px solid var(--rule-faint);cursor:pointer;transition:background 160ms ease}
+ .pick:hover{background:var(--noir-rise)}
+ .pick input{width:auto;padding:0;margin:0;accent-color:var(--metal)}
+ .pick .pn{flex:1;font-size:13.5px}
+ .pick .pc{font-variant-numeric:tabular-nums;color:var(--metal);font-size:12px;font-weight:500;letter-spacing:.04em}
+ .pricing-card{border:1px solid var(--rule);background:var(--noir-rise);padding:32px}
+ .pricing-card h3{font-family:var(--serif);font-weight:400;font-size:24px;margin:0 0 8px}
+ .pricing-card h3 em{font-style:italic;color:var(--metal)}
+ .pricing-card .price{font-family:var(--serif);font-weight:300;font-size:54px;line-height:1;color:var(--metal);font-variant-numeric:tabular-nums;margin:18px 0 8px}
+ .pricing-card .price-sub{font-size:13px;color:var(--ink-mute);margin-bottom:20px}
+ .pricing-card ul{list-style:none;padding:0;margin:0 0 24px}
+ .pricing-card li{padding:8px 0;border-bottom:1px solid var(--rule-faint);font-size:13px;color:var(--ink-soft);display:flex;justify-content:space-between}
+ .pricing-card li:before{content:"✓ ";color:var(--metal);margin-right:8px}
+ .pricing-card.sub{border-color:var(--metal);position:relative}
+ .pricing-card.sub:before{content:"Best value";position:absolute;top:-11px;left:24px;background:var(--metal);color:var(--noir);font-size:10px;letter-spacing:.18em;text-transform:uppercase;font-weight:500;padding:3px 10px}
+ .quick-pills{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0 18px}
+ .quick-pills button{background:var(--noir-rise);border:1px solid var(--rule);color:var(--ink-soft);padding:7px 14px;font-size:11px;letter-spacing:.1em;text-transform:uppercase;cursor:pointer}
+ .quick-pills button:hover{border-color:var(--metal);color:var(--metal)}
+ .selected-summary{padding:18px 20px;background:var(--noir-rise);border:1px solid var(--rule);margin-bottom:18px;font-size:13px}
+ .selected-summary b{color:var(--metal);font-variant-numeric:tabular-nums}
+ .compliance{padding:16px 18px;background:#1a1611;border:1px solid #4a3a1a;color:#d4a04a;font-size:11px;line-height:1.55;letter-spacing:.04em;margin-top:32px}
+ .compliance b{color:var(--metal-glow)}
+ label.field{display:block;margin-top:14px;font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-mute)}
+ label.field input{display:block;width:100%;margin-top:6px}
+ </style>
+ <p class="eyebrow">Data marketplace · ${totalDoctors.toLocaleString()} LA County licensed practitioners</p>
+ <h1 class="display">Doctor lists, <em>cleaned and current.</em></h1>
+ <p class="lede">Built from CMS's public NPPES NPI Registry, filtered to LA County, deduplicated by NPI. Pick by specialty, by city, or both — buy once for a snapshot, or subscribe for monthly refreshes as new clinicians are credentialed.</p>
+
+ ${flash}
+
+ <form method="post" action="/data/checkout" class="data-grid" id="orderForm">
+ <div>
+ <p class="eyebrow" style="margin-top:0">Step 01 — Pick your set</p>
+ <div class="quick-pills">
+ <button type="button" data-pick="MD">All physicians (MD/DO)</button>
+ <button type="button" data-pick="MENTAL">Mental health</button>
+ <button type="button" data-pick="DENTAL">All dentists</button>
+ <button type="button" data-pick="LA">All LA city</button>
+ <button type="button" data-pick="CLEAR">Clear</button>
+ </div>
+ <div class="picker-row">
+ <div class="picker">
+ <div class="picker-h">By specialty</div>
+ ${specialtyHtml}
+ </div>
+ <div class="picker">
+ <div class="picker-h">By city</div>
+ ${cityHtml}
+ </div>
+ </div>
+ <p class="muted" style="margin-top:14px;font-size:12px">${specialties.length} specialties · ${cities.length} cities · only categories with ≥25–50 practitioners shown · counts are de-duplicated by NPI · combine specialty + city for an AND filter.</p>
+ </div>
+
+ <div>
+ <div class="selected-summary" id="summary">
+ <div>Selected specialties: <b id="sumSpec">0</b></div>
+ <div>Selected cities: <b id="sumCity">0</b></div>
+ <div>Doctors in set: <b id="sumDoctors">${totalDoctors.toLocaleString()}</b> <span class="muted" style="font-size:11px">(estimate, refines on selection)</span></div>
+ </div>
+
+ <div class="pricing-card" style="margin-bottom:24px">
+ <h3>One-time <em>purchase</em></h3>
+ <div class="price" id="onetimePrice">$49</div>
+ <div class="price-sub">snapshot CSV · download today</div>
+ <ul>
+ <li><span>Format</span><span>CSV (Excel-compatible)</span></li>
+ <li><span>Fields</span><span>Name · NPI · Specialty · Practice · Phone · City · ZIP</span></li>
+ <li><span>Refunds</span><span>14-day, no-questions</span></li>
+ <li><span>License</span><span>Single use, no resale</span></li>
+ </ul>
+ <button class="btn" type="submit" name="kind" value="one_time" style="width:100%">Buy snapshot →</button>
+ </div>
+
+ <div class="pricing-card sub">
+ <h3>Subscription · <em>$99/mo</em></h3>
+ <div class="price">$99</div>
+ <div class="price-sub">all LA County · monthly refresh</div>
+ <ul>
+ <li><span>Coverage</span><span>All specialties + cities</span></li>
+ <li><span>Refresh</span><span>Monthly diff + full export</span></li>
+ <li><span>Cancel</span><span>Anytime, in 1 click</span></li>
+ <li><span>Format</span><span>CSV + JSON</span></li>
+ </ul>
+ <button class="btn ghost" type="submit" name="kind" value="subscription" style="width:100%">Start subscription →</button>
+ </div>
+
+ <label class="field">Email <input name="email" type="email" required placeholder="you@company.com" autocomplete="email"></label>
+ <label class="field">Name <input name="full_name" type="text" placeholder="Your name"></label>
+ <label class="field">Company <input name="company" type="text" placeholder="Optional"></label>
+ </div>
+ </form>
+
+ <div class="compliance">
+ <b>About this data.</b> Records sourced from the CMS National Plan and Provider Enumeration System (NPPES) — a public-domain federal registry of US healthcare providers — and filtered to LA County practice locations. All buyers must comply with TCPA, CAN-SPAM, CCPA/CPRA, and any state-specific medical-marketing rules. Data must not be used for unsolicited bulk SMS, robocalls, prescribing recommendations, or any unlawful purpose. We exclude practitioners who have opted out of directory listing.
+ </div>
+
+ <script>
+ const cbSpec = document.querySelectorAll('input[name="specialties"]');
+ const cbCity = document.querySelectorAll('input[name="cities"]');
+ const sumS = document.getElementById('sumSpec');
+ const sumC = document.getElementById('sumCity');
+ const sumD = document.getElementById('sumDoctors');
+ const otp = document.getElementById('onetimePrice');
+
+ function recompute(){
+ const sels = [...cbSpec].filter(c => c.checked);
+ const cels = [...cbCity].filter(c => c.checked);
+ sumS.textContent = sels.length;
+ sumC.textContent = cels.length;
+ // Crude estimate: if both dimensions picked, take the smaller of the two sums
+ // (real count comes from server-side AND query at checkout). If only one
+ // dimension picked, sum its rows.
+ const sSum = sels.reduce((a,c) => a + parseInt(c.dataset.doctors||'0',10), 0);
+ const cSum = cels.reduce((a,c) => a + parseInt(c.dataset.doctors||'0',10), 0);
+ let est;
+ if (sels.length && cels.length) est = Math.min(sSum, cSum);
+ else if (sels.length) est = sSum;
+ else if (cels.length) est = cSum;
+ else est = ${totalDoctors};
+ sumD.textContent = est.toLocaleString();
+ // Mirror computePrice() — picksCount = total selections across both dims.
+ const picks = sels.length + cels.length;
+ const base = ${ONETIME_BASE_CENTS};
+ const per = ${ONETIME_PER_DOCTOR_CENTS};
+ const cap = ${ONETIME_CAP_PER_PICK_CENTS} * Math.max(1, picks);
+ let raw = base + (est * per);
+ raw = Math.min(raw, cap);
+ if (picks >= 3) raw = Math.round(raw * 0.7);
+ raw = Math.max(raw, ${ONETIME_FLOOR_CENTS});
+ otp.textContent = '$' + (raw/100).toLocaleString(undefined,{minimumFractionDigits: raw%100?2:0, maximumFractionDigits:2});
+ }
+ cbSpec.forEach(c => c.addEventListener('change', recompute));
+ cbCity.forEach(c => c.addEventListener('change', recompute));
+ recompute();
+
+ const PICKS = {
+ MD: { specialties: [...cbSpec].filter(c => /internal|family|cardio|surgery|pediatric|emergency|radiology|anesthesi|psychiatry|dermatology|oncology|gastro|orthopaed|orthopedic|neurology/i.test(c.value)).map(c=>c.value), cities: [] },
+ MENTAL:{ specialties: [...cbSpec].filter(c => /psycho|marriage|social|mental|counsel|addict/i.test(c.value)).map(c=>c.value), cities: [] },
+ DENTAL:{ specialties: [...cbSpec].filter(c => /dent|orthodont|periodont/i.test(c.value)).map(c=>c.value), cities: [] },
+ LA: { specialties: [], cities: [...cbCity].filter(c => /^Los Angeles$/i.test(c.value)).map(c=>c.value) },
+ CLEAR: { specialties: [], cities: [] },
+ };
+ document.querySelectorAll('button[data-pick]').forEach(b => b.addEventListener('click', () => {
+ const set = PICKS[b.dataset.pick];
+ cbSpec.forEach(c => { c.checked = (set.specialties || []).includes(c.value); });
+ cbCity.forEach(c => { c.checked = (set.cities || []).includes(c.value); });
+ recompute();
+ }));
+ </script>
+ ` + FOOT);
+});
+
+// ─── POST /data/checkout — start Stripe Checkout ────────────────────────────
+
+router.post('/data/checkout', async (req, res) => {
+ if (!stripe) return res.redirect('/data?err=' + encodeURIComponent('Payments not configured'));
+ const b = req.body || {};
+ const kind = (b.kind === 'subscription') ? 'subscription' : 'one_time';
+ const email = String(b.email || '').trim().toLowerCase();
+ if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))
+ return res.redirect('/data?err=' + encodeURIComponent('Valid email required'));
+
+ // Normalize specialties + cities — POST may send single string or array.
+ const norm = (raw) => {
+ if (Array.isArray(raw)) return raw.map(s => String(s).trim()).filter(Boolean);
+ if (typeof raw === 'string' && raw.trim()) return [raw.trim()];
+ return [];
+ };
+ const specialties = norm(b.specialties);
+ const cities = norm(b.cities);
+
+ if (kind === 'one_time' && specialties.length === 0 && cities.length === 0)
+ return res.redirect('/data?err=' + encodeURIComponent('Pick at least one specialty or city'));
+
+ const doctorCount = kind === 'subscription'
+ ? parseInt((await query(`SELECT COUNT(*)::text AS n FROM professionals WHERE opted_out=false AND npi_number IS NOT NULL`)).rows[0].n, 10)
+ : await doctorCountForPicks(specialties, cities);
+
+ const picksCount = specialties.length + cities.length;
+ const amount = computePrice(kind, picksCount, doctorCount);
+ const downloadToken = crypto.randomBytes(32).toString('hex');
+
+ const ins = await query(`
+ INSERT INTO data_orders (
+ kind, email, full_name, company, specialties, cities, doctor_count, amount_cents,
+ download_token, ip, user_agent
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id
+ `, [
+ kind, email,
+ String(b.full_name || '').trim().slice(0, 120) || null,
+ String(b.company || '').trim().slice(0, 120) || null,
+ kind === 'subscription' ? [] : specialties,
+ kind === 'subscription' ? [] : cities,
+ doctorCount, amount, downloadToken,
+ req.ip || null, (req.headers['user-agent'] || '').toString().slice(0, 200),
+ ]);
+ const orderId = ins.rows[0].id;
+
+ // Codex P1 #2 fix: pinned base URL — never read from request headers.
+ const base = PUBLIC_BASE_URL;
+
+ // Hoisted out of try so the catch block can reach it for session.expire().
+ let session;
+ try {
+ if (kind === 'subscription') {
+ session = await stripe.checkout.sessions.create({
+ mode: 'subscription',
+ payment_method_types: ['card'],
+ customer_email: email,
+ line_items: [{
+ price_data: {
+ currency: 'usd',
+ recurring: { interval: 'month' },
+ product_data: {
+ name: 'Practitioner Index — Doctor Data, monthly subscription',
+ description: 'Unlimited LA County downloads · monthly refresh · cancel anytime',
+ },
+ unit_amount: amount,
+ },
+ quantity: 1,
+ }],
+ metadata: { kind: 'data_subscription', order_id: String(orderId) },
+ success_url: `${base}/data/thanks?session_id={CHECKOUT_SESSION_ID}`,
+ cancel_url: `${base}/data?err=${encodeURIComponent('Checkout cancelled')}`,
+ });
+ } else {
+ const desc = [
+ specialties.length ? `${specialties.slice(0, 3).join(', ')}${specialties.length > 3 ? ` +${specialties.length - 3}` : ''}` : null,
+ cities.length ? `${cities.slice(0, 3).join(', ')}${cities.length > 3 ? ` +${cities.length - 3}` : ''}` : null,
+ ].filter(Boolean).join(' · ');
+ session = await stripe.checkout.sessions.create({
+ mode: 'payment',
+ payment_method_types: ['card'],
+ customer_email: email,
+ line_items: [{
+ price_data: {
+ currency: 'usd',
+ product_data: {
+ name: `Practitioner Index — Doctor List (${picksCount} ${picksCount === 1 ? 'pick' : 'picks'})`,
+ description: `${doctorCount.toLocaleString()} LA County practitioners · ${desc}`,
+ },
+ unit_amount: amount,
+ },
+ quantity: 1,
+ }],
+ metadata: {
+ kind: 'data_one_time',
+ order_id: String(orderId),
+ specialties: specialties.slice(0, 8).join('|').slice(0, 240),
+ cities: cities.slice(0, 8).join('|').slice(0, 240),
+ doctor_count: String(doctorCount),
+ },
+ success_url: `${base}/data/thanks?session_id={CHECKOUT_SESSION_ID}`,
+ cancel_url: `${base}/data?err=${encodeURIComponent('Checkout cancelled')}`,
+ });
+ }
+ await query(`UPDATE data_orders SET payment_link = $2, stripe_session_id = $3 WHERE id = $1`,
+ [orderId, session.url, session.id]);
+ res.redirect(303, session.url || '/data');
+ } catch (e) {
+ // Codex P2 #6 fix: don't leave a zombie pending_payment row with a live
+ // download_token if Stripe checkout creation failed. Mark cancelled so
+ // it doesn't sit in admin queue forever and the token is dead.
+ //
+ // Cycle-2 Claude review P1: if Stripe.create() succeeded but the local
+ // UPDATE for stripe_session_id failed, the buyer can still complete
+ // payment against an orphan session that the /thanks UPDATE will reject
+ // (because stripe_session_id is null in the row). Expire the Stripe
+ // session as a safety net so the buyer can't pay into a dead order.
+ console.error('[data] checkout error:', e);
+ try {
+ await query(`UPDATE data_orders SET status='cancelled', cancelled_at=NOW() WHERE id=$1 AND status='pending_payment'`, [orderId]);
+ } catch (uErr) { console.error('[data] checkout cleanup error:', uErr.message); }
+ if (session?.id && stripe) {
+ try { await stripe.checkout.sessions.expire(session.id); }
+ catch (expErr) { console.error('[data] session expire failed:', expErr.message); }
+ }
+ res.redirect('/data?err=' + encodeURIComponent('Stripe error: ' + e.message.slice(0, 160)));
+ }
+});
+
+// ─── GET /data/thanks — Stripe redirect, mark paid ──────────────────────────
+
+router.get('/data/thanks', async (req, res) => {
+ const sid = String(req.query.session_id || '');
+ let paid = false;
+ let token = '';
+ let kind = 'one_time';
+ if (stripe && sid) {
+ try {
+ const s = await stripe.checkout.sessions.retrieve(sid);
+ const orderId = s.metadata?.order_id ? parseInt(s.metadata.order_id, 10) : null;
+ const subId = (typeof s.subscription === 'string') ? s.subscription : s.subscription?.id;
+ const custId = (typeof s.customer === 'string') ? s.customer : s.customer?.id;
+ const isPaid = s.payment_status === 'paid' || s.payment_status === 'no_payment_required';
+ if (orderId && isPaid) {
+ // Codex P1 #3 fix: require stripe_session_id match before marking paid.
+ // A poisoned redirect with a different (or replayed) session_id can't
+ // flip the wrong order. The session_id comes from the Stripe URL — we
+ // re-retrieved it above, so it's authoritative.
+ const r = await query(`
+ UPDATE data_orders
+ SET status = 'paid', paid_at = NOW(),
+ stripe_subscription_id = COALESCE($2, stripe_subscription_id),
+ stripe_customer_id = COALESCE($3, stripe_customer_id)
+ WHERE id = $1 AND stripe_session_id = $4 AND status = 'pending_payment'
+ RETURNING download_token, kind
+ `, [orderId, subId || null, custId || null, sid]);
+ if (r.rowCount && r.rows[0]) {
+ paid = true;
+ token = r.rows[0].download_token;
+ kind = r.rows[0].kind === 'subscription' ? 'subscription' : 'one_time';
+ } else {
+ // Already paid on a prior page-load — re-read. Also gate on
+ // stripe_session_id (cycle-2 Claude review): the UPDATE branch
+ // already requires it, so the fallback must too — otherwise an
+ // attacker holding a different valid Stripe session for the same
+ // order_id could fetch the download_token by replaying /thanks.
+ const r2 = await query(
+ `SELECT download_token, kind, status FROM data_orders WHERE id = $1 AND stripe_session_id = $2`,
+ [orderId, sid]
+ );
+ if (r2.rowCount && r2.rows[0].status === 'paid') {
+ paid = true;
+ token = r2.rows[0].download_token;
+ kind = r2.rows[0].kind === 'subscription' ? 'subscription' : 'one_time';
+ }
+ }
+ }
+ } catch (e) {
+ console.error('[data] thanks lookup err:', e);
+ }
+ }
+ const dlUrl = token ? `/data/download/${token}` : '';
+ const body = paid
+ ? `<p class="eyebrow">Order received · payment confirmed</p>
+ <h1 class="display">Your data is <em>ready.</em></h1>
+ <p class="lede">${kind === 'subscription'
+ ? 'Your subscription is active. Bookmark this page — your token is the same key for every monthly refresh.'
+ : 'Your one-time export is ready. The download link below is yours; bookmark it for the next 30 days.'}</p>
+ <p style="margin:30px 0">
+ <a href="${esc(dlUrl)}" class="btn" style="display:inline-block;background:var(--metal);color:var(--noir);padding:16px 28px;font-size:12px;letter-spacing:.18em;text-transform:uppercase;font-weight:500;text-decoration:none;border:1px solid var(--metal)">Download CSV →</a>
+ </p>
+ <p class="muted">Bookmark this URL — your private download link:<br><code style="background:var(--noir-rise);padding:8px 14px;display:inline-block;margin-top:8px;border:1px solid var(--rule);color:var(--ink-soft);font-size:12px">${esc(dlUrl)}</code></p>`
+ : `<p class="eyebrow">Almost there</p>
+ <h1 class="display">Reservation noted.</h1>
+ <p class="lede">If your payment is still processing, give it a moment and refresh. If you cancelled, no charge was made.</p>
+ <p><a href="/data">← Back to data</a></p>`;
+ res.send(shellHead('Thanks') + body + FOOT);
+});
+
+// ─── GET /data/download/:token — CSV export ─────────────────────────────────
+
+router.get('/data/download/:token', async (req, res) => {
+ const token = String(req.params.token || '').trim();
+ if (!/^[a-f0-9]{32,128}$/i.test(token))
+ return res.status(400).send('Invalid token format');
+
+ const r = await query(`
+ SELECT id, kind, status, specialties, cities, stripe_subscription_id, paid_at,
+ download_token
+ FROM data_orders WHERE download_token = $1`, [token]);
+ if (!r.rowCount) return res.status(404).send('Token not found');
+ const o = r.rows[0];
+ // Cycle-2 Claude review P2: constant-time compare of stored vs supplied
+ // token. The B-tree lookup above is already O(log n) and the UNIQUE index
+ // makes a strict equality check, but B-tree comparisons short-circuit on
+ // first mismatching byte; the constant-time check defends against any
+ // residual timing oracle.
+ try {
+ const a = Buffer.from(o.download_token);
+ const b = Buffer.from(token);
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
+ return res.status(404).send('Token not found');
+ }
+ } catch { return res.status(404).send('Token not found'); }
+ if (o.status !== 'paid') return res.status(402).send('Order not paid');
+
+ // Codex P2 #8 fix: one-time purchases are described in copy as a 30-day
+ // download window — enforce it here. After 30 days from `paid_at` the
+ // token expires (subscription downloads are gated on Stripe state below).
+ if (o.kind === 'one_time' && o.paid_at) {
+ const ageDays = (Date.now() - new Date(o.paid_at).getTime()) / 86400000;
+ if (ageDays > 30) {
+ return res.status(410).send('Download window expired (30 days). Re-purchase or contact support.');
+ }
+ }
+
+ // Subscription: verify Stripe subscription is still active.
+ if (o.kind === 'subscription') {
+ if (!stripe) return res.status(503).send('Subscription verification unavailable');
+ if (!o.stripe_subscription_id) return res.status(402).send('Subscription not on file');
+ try {
+ const sub = await stripe.subscriptions.retrieve(o.stripe_subscription_id);
+ if (sub.status !== 'active' && sub.status !== 'trialing') {
+ await query(`UPDATE data_orders SET status='sub_cancelled', cancelled_at=NOW() WHERE id=$1`, [o.id]);
+ return res.status(402).send('Subscription is no longer active');
+ }
+ } catch (e) {
+ console.error('[data] sub verify err:', e);
+ return res.status(502).send('Could not verify subscription');
+ }
+ }
+
+ // Build the CSV — subscription returns all-LA, one_time uses the recorded picks.
+ const params = [];
+ const where = [`p.opted_out=false`, `p.npi_number IS NOT NULL`];
+ if (o.kind !== 'subscription') {
+ if ((o.specialties || []).length) {
+ params.push(o.specialties);
+ where.push(`p.primary_specialty = ANY($${params.length}::text[])`);
+ }
+ if ((o.cities || []).length) {
+ params.push(o.cities.map(c => c.toLowerCase()));
+ where.push(`LOWER(o.city) = ANY($${params.length}::text[])`);
+ }
+ }
+ // Codex P2 #7 fix: city pricing/CSV mismatch. The price quote (count) joins
+ // ANY professional_location and matches city against any of them. The CSV
+ // used to join only `pl.is_primary`, so a doctor with secondary LA practice
+ // would price-in but get omitted from the export. Resolution: use EXISTS
+ // for the city predicate (matches count semantics), then pick the primary
+ // location for contact display in the SELECT.
+ for (let i = 0; i < where.length; i++) {
+ if (where[i].startsWith('LOWER(o.city)')) {
+ // Replace the inline join-side filter with an EXISTS subquery that
+ // matches ANY of the doctor's locations.
+ const paramIdx = where[i].match(/\$(\d+)/)[1];
+ where[i] = `EXISTS (
+ SELECT 1 FROM professional_locations pl2
+ JOIN organizations o2 ON o2.id = pl2.organization_id
+ WHERE pl2.professional_id = p.id
+ AND LOWER(o2.city) = ANY($${paramIdx}::text[])
+ )`;
+ break;
+ }
+ }
+ const rows = await query(`
+ SELECT DISTINCT ON (p.id)
+ p.npi_number, p.full_name, p.first_name, p.last_name, p.title,
+ p.primary_specialty, p.license_number, p.license_status,
+ o.name AS practice_name, COALESCE(pl.phone, o.phone) AS phone,
+ pl.address, o.city, o.state, o.zip
+ FROM professionals p
+ LEFT JOIN professional_locations pl ON pl.professional_id = p.id AND pl.is_primary
+ LEFT JOIN organizations o ON o.id = pl.organization_id
+ WHERE ${where.join(' AND ')}
+ ORDER BY p.id`, params);
+
+ await query(`UPDATE data_orders SET downloads_count = downloads_count + 1, last_downloaded_at = NOW() WHERE id = $1`, [o.id]);
+
+ const today = new Date().toISOString().slice(0, 10);
+ const fname = `practitioner-index-doctors-${today}.csv`;
+ res.setHeader('Content-Type', 'text/csv; charset=utf-8');
+ res.setHeader('Content-Disposition', `attachment; filename="${fname}"`);
+ const csvCell = (v) => '"' + (v == null ? '' : String(v)).replace(/"/g, '""') + '"';
+ res.write(['NPI','Full Name','First','Last','Title','Specialty','License','License Status','Practice','Phone','Address','City','State','ZIP'].map(csvCell).join(',') + '\n');
+ for (const row of rows.rows) {
+ res.write([
+ row.npi_number, row.full_name, row.first_name, row.last_name, row.title,
+ row.primary_specialty, row.license_number, row.license_status,
+ row.practice_name, row.phone, row.address, row.city, row.state, row.zip,
+ ].map(csvCell).join(',') + '\n');
+ }
+ res.end();
+});
+
+// ─── /admin/data-orders ─────────────────────────────────────────────────────
+
+router.get('/admin/data-orders', (req, res, next) => {
+ // Phase 1 auth gate: req.user is set either by passport session OR by the
+ // loopback synthetic-admin middleware in server.js (gated by
+ // ADMIN_LOOPBACK_TRUST=1). Public callers via tunnel get no bypass.
+ if (!req.user || req.user.role !== 'admin') {
+ return res.status(req.user ? 403 : 401).json({ error: req.user ? 'forbidden' : 'auth required' });
+ }
+ next();
+}, async (req, res) => {
+ const rows = await query(`
+ SELECT id, kind, email, full_name, company, specialties, cities, doctor_count,
+ amount_cents, status, downloads_count, paid_at, created_at, payment_link
+ FROM data_orders ORDER BY id DESC LIMIT 200`, []);
+
+ const counts = await query(`
+ SELECT 'total' AS k, COUNT(*)::text AS v FROM data_orders
+ UNION ALL SELECT 'paid', COUNT(*)::text FROM data_orders WHERE status='paid'
+ UNION ALL SELECT 'subs', COUNT(*)::text FROM data_orders WHERE kind='subscription' AND status='paid'
+ UNION ALL SELECT 'revenue', COALESCE(SUM(amount_cents),0)::text FROM data_orders WHERE status='paid'
+ `, []);
+ const cm = {}; for (const r of counts.rows) cm[r.k] = r.v;
+
+ const trs = rows.rows.map(o => {
+ const pickStr = o.kind === 'subscription'
+ ? 'all LA'
+ : [...(o.specialties || []).slice(0, 2), ...(o.cities || []).slice(0, 2)].join(', ') +
+ (((o.specialties || []).length + (o.cities || []).length) > 4 ? ' …' : '');
+ return `<tr>
+ <td>#${o.id}<br><span class="muted">${new Date(o.created_at).toISOString().slice(0,16).replace('T',' ')}</span></td>
+ <td><b>${esc(o.email)}</b><br><span class="muted">${esc(o.full_name || '—')} · ${esc(o.company || '—')}</span></td>
+ <td>${esc(o.kind)}<br><span class="muted">${esc(pickStr)}</span></td>
+ <td>${o.doctor_count != null ? o.doctor_count.toLocaleString() : '—'}</td>
+ <td>${fmtCents(o.amount_cents)}<br><span class="muted">downloads: ${o.downloads_count}</span></td>
+ <td><span class="pill ${esc(o.status)}">${esc(o.status)}</span>${o.paid_at ? `<br><span class="muted">${new Date(o.paid_at).toISOString().slice(0,10)}</span>` : ''}</td>
+ <td>${o.payment_link ? `<a href="${esc(o.payment_link)}" target="_blank">Stripe ↗</a>` : '<span class="muted">—</span>'}</td>
+ </tr>`;
+ }).join('');
+
+ res.send(shellHead('Admin · Data Orders', { wide: true }) + `
+ <p class="eyebrow">Admin · Data marketplace</p>
+ <h1 class="display">Data <em>orders.</em></h1>
+ <div style="display:flex;gap:32px;margin:24px 0 32px;font-family:var(--serif)">
+ <div><div style="font-size:32px;color:var(--metal);font-variant-numeric:tabular-nums">${cm.total || 0}</div><div class="muted" style="font-size:11px;letter-spacing:.18em;text-transform:uppercase">total orders</div></div>
+ <div><div style="font-size:32px;color:var(--good);font-variant-numeric:tabular-nums">${cm.paid || 0}</div><div class="muted" style="font-size:11px;letter-spacing:.18em;text-transform:uppercase">paid</div></div>
+ <div><div style="font-size:32px;color:var(--metal);font-variant-numeric:tabular-nums">${cm.subs || 0}</div><div class="muted" style="font-size:11px;letter-spacing:.18em;text-transform:uppercase">active subs</div></div>
+ <div><div style="font-size:32px;color:var(--metal-glow);font-variant-numeric:tabular-nums">$${(parseInt(cm.revenue || '0', 10) / 100).toLocaleString()}</div><div class="muted" style="font-size:11px;letter-spacing:.18em;text-transform:uppercase">gross revenue</div></div>
+ </div>
+ <table>
+ <thead><tr><th>#</th><th>Buyer</th><th>Kind / Picks</th><th>Doctors</th><th>Amount</th><th>Status</th><th>Stripe</th></tr></thead>
+ <tbody>${trs || '<tr><td colspan="7" class="muted" style="padding:32px;text-align:center">No orders yet.</td></tr>'}</tbody>
+ </table>
+ ` + FOOT);
+});
+
+// ─── Stripe webhook handler ─────────────────────────────────────────────────
+//
+// Codex P1 #1 fix: this handler is exported separately so server.js can mount
+// it with `express.raw({type:'application/json'})` BEFORE the global
+// `express.json()` body parser. If the global parser runs first, `req.body` is
+// a parsed object and `stripe.webhooks.constructEvent()` rejects every event
+// — silently breaking webhook processing in prod.
+//
+// Codex P1 #3 fix: webhook UPDATE includes `stripe_session_id=$N` in the WHERE
+// clause and verifies amount_total + mode + metadata kind before transitioning
+// to 'paid', so a poisoned redirect (or replayed metadata.order_id) can't mark
+// the wrong order paid.
+async function stripeWebhookHandler(req, res) {
+ const secret = process.env.STRIPE_DATA_WEBHOOK_SECRET;
+ if (!stripe || !secret) return res.status(503).send('webhook not configured');
+ const sig = req.headers['stripe-signature'];
+ let event;
+ try { event = stripe.webhooks.constructEvent(req.body, sig, secret); }
+ catch (e) { return res.status(400).send(`webhook signature err: ${e.message}`); }
+
+ try {
+ switch (event.type) {
+ case 'checkout.session.completed': {
+ const s = event.data.object;
+ const orderId = s.metadata?.order_id ? parseInt(s.metadata.order_id, 10) : null;
+ const expectedKind = s.metadata?.kind === 'data_subscription' ? 'subscription' : 'one_time';
+ if (!orderId || (s.metadata?.kind !== 'data_one_time' && s.metadata?.kind !== 'data_subscription')) {
+ break;
+ }
+ const subId = (typeof s.subscription === 'string') ? s.subscription : s.subscription?.id;
+ const custId = (typeof s.customer === 'string') ? s.customer : s.customer?.id;
+ // Look up the stored order and verify shape before marking paid.
+ const r = await query(
+ `SELECT kind, amount_cents, status FROM data_orders WHERE id=$1 AND stripe_session_id=$2`,
+ [orderId, s.id]
+ );
+ if (!r.rowCount) {
+ console.warn(`[data webhook] no matching order id=${orderId} session=${s.id}`);
+ break;
+ }
+ const stored = r.rows[0];
+ if (stored.status === 'paid') break; // idempotent — already done
+ if (stored.kind !== expectedKind) {
+ console.warn(`[data webhook] kind mismatch order=${orderId} stored=${stored.kind} session=${expectedKind}`);
+ break;
+ }
+ // For one-time payments Stripe sends amount_total; for subscriptions
+ // Stripe doesn't include it on the session — skip the dollar check.
+ if (s.mode === 'payment' && typeof s.amount_total === 'number'
+ && s.amount_total !== stored.amount_cents) {
+ console.warn(`[data webhook] amount mismatch order=${orderId} stored=${stored.amount_cents} session=${s.amount_total}`);
+ break;
+ }
+ await query(`
+ UPDATE data_orders
+ SET status='paid', paid_at=NOW(),
+ stripe_subscription_id = COALESCE($2, stripe_subscription_id),
+ stripe_customer_id = COALESCE($3, stripe_customer_id)
+ WHERE id = $1 AND stripe_session_id = $4 AND status='pending_payment'`,
+ [orderId, subId || null, custId || null, s.id]);
+ break;
+ }
+ case 'customer.subscription.deleted': {
+ const sub = event.data.object;
+ await query(`UPDATE data_orders SET status='sub_cancelled', cancelled_at=NOW()
+ WHERE stripe_subscription_id = $1 AND status='paid'`, [sub.id]);
+ // Phase 1 user-tier sync: if this subscription belongs to a pd-api
+ // user (mirror in subscriptions table), flip them back to 'free' and
+ // record the end date.
+ await query(`UPDATE subscriptions SET status='canceled', ended_at=NOW(), raw_json=$2::jsonb
+ WHERE stripe_subscription_id = $1`, [sub.id, JSON.stringify(sub)]);
+ await query(`UPDATE users SET tier='free' WHERE id IN
+ (SELECT user_id FROM subscriptions WHERE stripe_subscription_id = $1)`, [sub.id]);
+ break;
+ }
+ case 'customer.subscription.created':
+ case 'customer.subscription.updated': {
+ const sub = event.data.object;
+ const custId = (typeof sub.customer === 'string') ? sub.customer : sub.customer?.id;
+ const priceId = sub.items?.data?.[0]?.price?.id || '';
+ const planMeta = sub.metadata?.plan
+ || (priceId === process.env.STRIPE_PRICE_DOCTOR_PRO ? 'doctor_pro_monthly'
+ : priceId === process.env.STRIPE_PRICE_PATIENT_PLUS ? 'patient_plus_monthly'
+ : 'unknown');
+ // Locate pd-api user by stripe_customer_id.
+ const u = (await query('SELECT id FROM users WHERE stripe_customer_id = $1', [custId])).rows[0];
+ if (!u) break; // belongs to data_market data_orders (older flow), not user-tier
+ const periodEnd = sub.current_period_end ? new Date(sub.current_period_end * 1000) : null;
+ await query(`
+ INSERT INTO subscriptions (user_id, stripe_subscription_id, stripe_price_id, plan,
+ status, current_period_end, cancel_at_period_end, raw_json)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
+ ON CONFLICT (stripe_subscription_id) DO UPDATE SET
+ status = EXCLUDED.status,
+ current_period_end = EXCLUDED.current_period_end,
+ cancel_at_period_end = EXCLUDED.cancel_at_period_end,
+ raw_json = EXCLUDED.raw_json
+ `, [u.id, sub.id, priceId, planMeta, sub.status, periodEnd, !!sub.cancel_at_period_end, JSON.stringify(sub)]);
+ // Tier flip: 'active' or 'trialing' → paid; anything else → free.
+ const tier = (sub.status === 'active' || sub.status === 'trialing') ? 'paid' : 'free';
+ await query('UPDATE users SET tier = $1 WHERE id = $2', [tier, u.id]);
+ break;
+ }
+ }
+ } catch (e) {
+ console.error('[data webhook] handler error:', e.message);
+ // Return 200 so Stripe doesn't retry our bug indefinitely.
+ }
+ res.json({ received: true });
+}
+
+module.exports = router;
+module.exports.stripeWebhookHandler = stripeWebhookHandler;
diff --git a/agents/api-agent/routes/admin-pitch.js b/agents/api-agent/routes/admin-pitch.js
new file mode 100644
index 0000000..457994f
--- /dev/null
+++ b/agents/api-agent/routes/admin-pitch.js
@@ -0,0 +1,129 @@
+/**
+ * /admin/pitch — pitch funnel admin tools.
+ *
+ * GET /admin/pitch/queue — unclaimed orgs ranked by lead score
+ * GET /admin/pitch/:orgId/preview — bundle of mockup URLs + email recipients
+ * POST /admin/pitch/:orgId/send — fire pitch email via George gmail
+ *
+ * The "3 variant" pitch: each org already has 1 generated mockup at
+ * data/mockups/<id>.html (template-rotation). The pitch email links to:
+ * - /preview/:orgId (the basic /preview from pd-api)
+ * - /mockups/:orgId (the bespoke template-rotated mockup)
+ * - the org's CURRENT website (if one exists)
+ * That's 3 concrete options for the prospect to compare.
+ */
+const express = require('express');
+const fs = require('fs');
+const path = require('path');
+const { fetch } = require('undici');
+const { query } = require('../../shared/db');
+const { requireRole } = require('../auth');
+
+const router = express.Router();
+router.use(requireRole('admin'));
+
+const GEORGE_BASE = process.env.GEORGE_BASE || 'http://127.0.0.1:9850';
+const PUBLIC_BASE = process.env.PD_PUBLIC_BASE || 'http://127.0.0.1:9876';
+const MOCKUPS_DIR = path.resolve(__dirname, '../../../data/mockups');
+
+router.get('/queue', async (req, res, next) => {
+ try {
+ const limit = Math.min(Number(req.query.limit) || 50, 200);
+ const r = await query(`
+ SELECT o.id, o.name, o.type, o.city, o.zip, o.website, o.lead_score,
+ o.contacted_at, o.lat, o.lng,
+ (SELECT COUNT(*) FROM emails e WHERE e.organization_id = o.id) AS emails,
+ (SELECT email FROM emails e WHERE e.organization_id = o.id ORDER BY id LIMIT 1) AS primary_email
+ FROM organizations o
+ WHERE o.opted_out = false
+ AND o.claim_status = 'unclaimed'
+ AND EXISTS (SELECT 1 FROM emails e WHERE e.organization_id = o.id)
+ ORDER BY (o.contacted_at IS NULL) DESC, o.lead_score DESC NULLS LAST
+ LIMIT ${limit}
+ `);
+ // Annotate with mockup availability.
+ const rows = r.rows.map(o => ({
+ ...o,
+ has_mockup: fs.existsSync(path.join(MOCKUPS_DIR, `${o.id}.html`)),
+ }));
+ res.json({ count: rows.length, rows });
+ } catch (e) { next(e); }
+});
+
+router.get('/:orgId/preview', async (req, res, next) => {
+ try {
+ const id = Number(req.params.orgId);
+ const o = (await query(`SELECT * FROM organizations WHERE id=$1`, [id])).rows[0];
+ if (!o) return res.status(404).json({ error: 'not found' });
+ const emails = (await query(`SELECT email, email_type FROM emails WHERE organization_id=$1`, [id])).rows;
+ const hasMockup = fs.existsSync(path.join(MOCKUPS_DIR, `${id}.html`));
+ res.json({
+ org: o,
+ emails,
+ pitch_assets: {
+ preview_url: `${PUBLIC_BASE.replace(':9876', ':9874')}/preview/${id}`,
+ mockup_url: hasMockup ? `${PUBLIC_BASE.replace(':9876', ':9874')}/mockups/${id}.html` : null,
+ public_org_page: `${PUBLIC_BASE}/orgs/${id}`,
+ current_website: o.website || null,
+ },
+ });
+ } catch (e) { next(e); }
+});
+
+router.post('/:orgId/send', async (req, res, next) => {
+ try {
+ const id = Number(req.params.orgId);
+ const toEmail = String(req.body?.to || '').trim();
+ if (!toEmail || !/.+@.+/.test(toEmail)) return res.status(400).json({ error: 'to (email) required' });
+ const o = (await query(`SELECT id, name, city, type, website FROM organizations WHERE id=$1`, [id])).rows[0];
+ if (!o) return res.status(404).json({ error: 'org not found' });
+ const hasMockup = fs.existsSync(path.join(MOCKUPS_DIR, `${id}.html`));
+
+ // Pitch assets — admin can override these in the body if needed.
+ const previewUrl = req.body?.preview_url || `${PUBLIC_BASE.replace(':9876', ':9874')}/preview/${id}`;
+ const mockupUrl = req.body?.mockup_url || (hasMockup ? `${PUBLIC_BASE.replace(':9876', ':9874')}/mockups/${id}.html` : null);
+ const orgPageUrl = req.body?.org_url || `${PUBLIC_BASE}/orgs/${id}`;
+
+ const subject = req.body?.subject || `A modern site for ${o.name} (drafted, free to look)`;
+
+ const links = [
+ mockupUrl ? ` 1. The bespoke draft (built just for ${o.name}):\n ${mockupUrl}` : null,
+ previewUrl ? ` 2. A clean editorial revamp of your existing data:\n ${previewUrl}` : null,
+ o.website ? ` 3. Compared to your current site:\n ${o.website}` : null,
+ ].filter(Boolean).join('\n\n');
+
+ const body = req.body?.body || `Hi —
+
+I'm Steve Abrams, an LA entrepreneur and small-business owner running Agent Abrams. I built three website concepts for ${o.name}${o.city ? ' (' + o.city + ')' : ''} that you can look at right now — no chat, no demo, no commitment:
+
+${links}
+
+Everything on the drafts is your real data — address, phone, services. The look and copy is mine to revise. If any direction feels right, reply with a yes and I put it live within a week. If none of them work, no harm done — close the tab.
+
+— Steve Abrams
+ steve@designerwallcoverings.com
+ Agent Abrams · LA entrepreneur and small business owner`;
+
+ // Send via George Gmail. Disable in tests by setting GEORGE_DISABLE=1.
+ let sent = false, georgeErr = null;
+ if (!process.env.GEORGE_DISABLE) {
+ try {
+ const r = await fetch(`${GEORGE_BASE}/api/send`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ to: toEmail, subject, body }),
+ signal: AbortSignal.timeout(20_000),
+ });
+ sent = r.ok;
+ if (!r.ok) georgeErr = `george http ${r.status}`;
+ } catch (e) { georgeErr = e.message; }
+ } else { sent = true; georgeErr = '(GEORGE_DISABLE=1, no email sent)'; }
+
+ // Record contact regardless of George success — admin can re-send later.
+ await query(`UPDATE organizations SET contacted_at = COALESCE(contacted_at, now()), updated_at = now() WHERE id = $1`, [id]);
+
+ res.json({ ok: true, sent, to: toEmail, mockup_used: !!mockupUrl, george_status: georgeErr || 'sent' });
+ } catch (e) { next(e); }
+});
+
+module.exports = router;
diff --git a/agents/api-agent/routes/auth.js b/agents/api-agent/routes/auth.js
new file mode 100644
index 0000000..963bf3b
--- /dev/null
+++ b/agents/api-agent/routes/auth.js
@@ -0,0 +1,61 @@
+/**
+ * /auth/* routes — Google OAuth login, logout, /whoami.
+ *
+ * Mounted in agents/api-agent/server.js after session middleware.
+ */
+const express = require('express');
+const { passport, authConfigured } = require('../auth');
+
+const router = express.Router();
+
+// Google OAuth start.
+router.get('/google', (req, res, next) => {
+ if (!authConfigured()) return res.status(503).json({ error: 'Google OAuth not configured (set GOOGLE_OAUTH_CLIENT_ID / SECRET)' });
+ // Passport reads `returnTo` and bounces back here after callback.
+ // SECURITY: `/foo` is OK; `//evil.com/x` is NOT — that's an open-redirect
+ // (browser treats it as protocol-relative, sends user off-site).
+ const returnTo = String(req.query.returnTo || '/');
+ req.session.returnTo = /^\/(?!\/)/.test(returnTo) ? returnTo : '/';
+ passport.authenticate('google', { scope: ['email', 'profile'] })(req, res, next);
+});
+
+// Google OAuth callback — passport completes session login here.
+router.get('/google/callback',
+ (req, res, next) => {
+ if (!authConfigured()) return res.status(503).json({ error: 'Google OAuth not configured' });
+ next();
+ },
+ passport.authenticate('google', { failureRedirect: '/auth/google?failed=1' }),
+ (req, res) => {
+ const target = req.session.returnTo || '/';
+ delete req.session.returnTo;
+ res.redirect(target);
+ });
+
+router.post('/logout', (req, res, next) => {
+ req.logout(err => {
+ if (err) return next(err);
+ req.session.destroy(() => res.json({ ok: true }));
+ });
+});
+
+// JSON identity endpoint — returns session user or null.
+router.get('/whoami', (req, res) => {
+ if (!req.user) return res.json({ user: null });
+ const u = req.user;
+ res.json({
+ user: {
+ id: u.id,
+ email: u.email,
+ role: u.role,
+ tier: u.tier,
+ display_name: u.display_name,
+ avatar_url: u.avatar_url,
+ claimed_professional_id: u.claimed_professional_id,
+ claimed_organization_id: u.claimed_organization_id,
+ synthetic: !!u._synthetic,
+ },
+ });
+});
+
+module.exports = router;
diff --git a/agents/api-agent/routes/chat.js b/agents/api-agent/routes/chat.js
new file mode 100644
index 0000000..7d5a37e
--- /dev/null
+++ b/agents/api-agent/routes/chat.js
@@ -0,0 +1,99 @@
+/**
+ * /api/chat — AI chat for the per-org mockup pages.
+ *
+ * Each org's mockup gets a floating chat widget that lets visitors ask
+ * questions about the practice (hours, services, location, providers).
+ * The chat uses LOCAL Ollama gemma3:12b on MS2 (no Anthropic, no OpenAI),
+ * with a system prompt populated from the org's actual data so answers
+ * stay grounded.
+ *
+ * POST /api/chat
+ * body: { orgId: number, message: string, history?: [{role, content}] }
+ * resp: { reply: string }
+ */
+const express = require('express');
+const { fetch } = require('undici');
+const { query } = require('../../shared/db');
+
+const router = express.Router();
+const OLLAMA = process.env.OLLAMA_BASE || 'http://127.0.0.1:11434';
+const MODEL = process.env.CHAT_MODEL || 'gemma3:12b';
+
+async function buildContext(orgId) {
+ const o = (await query(`SELECT id, name, type, address, city, state, zip, phone, website FROM organizations WHERE id=$1 AND opted_out=false`, [orgId])).rows[0];
+ if (!o) return null;
+ const pros = (await query(`
+ SELECT p.full_name, p.title, p.primary_specialty
+ FROM professional_locations pl JOIN professionals p ON p.id=pl.professional_id
+ WHERE pl.organization_id=$1 AND p.opted_out=false
+ ORDER BY p.source_confidence_score DESC NULLS LAST LIMIT 8`, [orgId])).rows;
+ const emails = (await query(`SELECT email, email_type FROM emails WHERE organization_id=$1 LIMIT 3`, [orgId])).rows;
+ return { o, pros, emails };
+}
+
+function systemPrompt(ctx) {
+ const { o, pros, emails } = ctx;
+ const lines = [
+ `You are a friendly receptionist for "${o.name}" (a ${o.type.replace(/_/g,' ')}) in ${o.city || 'Los Angeles County'}.`,
+ `Answer questions ONLY about this practice. If you don't know, say so and suggest the visitor call.`,
+ `Be brief — 2-3 sentences max. No medical advice; for clinical questions, tell them to call the office.`,
+ ``,
+ `--- Practice details ---`,
+ o.address ? `Address: ${o.address}, ${o.city}, ${o.state} ${o.zip}` : '',
+ o.phone ? `Phone: ${o.phone}` : '',
+ o.website ? `Website: ${o.website}` : '',
+ emails.length ? `Email: ${emails.map(e => e.email).join(', ')}` : '',
+ pros.length ? `\nProviders on staff: ${pros.map(p => `${p.full_name}${p.title ? ', ' + p.title : ''}${p.primary_specialty ? ' (' + p.primary_specialty + ')' : ''}`).join('; ')}` : '',
+ ``,
+ `Hours (default): Mon-Fri 9 AM - 5 PM. Sat by appointment. Closed Sun.`,
+ ].filter(Boolean);
+ return lines.join('\n');
+}
+
+router.post('/', async (req, res, next) => {
+ try {
+ const orgId = Number(req.body?.orgId);
+ const message = String(req.body?.message || '').slice(0, 800).trim();
+ if (!orgId || !message) return res.status(400).json({ error: 'orgId + message required' });
+
+ const ctx = await buildContext(orgId);
+ if (!ctx) return res.status(404).json({ error: 'org not found' });
+
+ const sys = systemPrompt(ctx);
+ const history = Array.isArray(req.body?.history) ? req.body.history.slice(-6) : [];
+
+ const ac = new AbortController();
+ const t = setTimeout(() => ac.abort(), 30_000);
+ let reply = '';
+ try {
+ const r = await fetch(`${OLLAMA}/api/chat`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ model: MODEL,
+ stream: false,
+ options: { temperature: 0.4, num_ctx: 4096 },
+ messages: [
+ { role: 'system', content: sys },
+ ...history,
+ { role: 'user', content: message },
+ ],
+ }),
+ signal: ac.signal,
+ });
+ clearTimeout(t);
+ if (!r.ok) {
+ return res.status(502).json({ error: 'Ollama upstream error' });
+ }
+ const j = await r.json();
+ reply = j?.message?.content || '(no response)';
+ } catch (e) {
+ clearTimeout(t);
+ return res.status(504).json({ error: 'Ollama timeout — check that gemma3:12b is loaded on MS2' });
+ }
+
+ res.json({ reply, model: MODEL });
+ } catch (e) { next(e); }
+});
+
+module.exports = router;
diff --git a/agents/api-agent/routes/claims.js b/agents/api-agent/routes/claims.js
new file mode 100644
index 0000000..f03c33f
--- /dev/null
+++ b/agents/api-agent/routes/claims.js
@@ -0,0 +1,256 @@
+/**
+ * /doctor-claims — submit a claim, verify via email-link, admin moderate.
+ *
+ * POST /doctor-claims — submit (auth required)
+ * GET /doctor-claims/:token — email-link verifier
+ * GET /doctor-claims/mine — my claims (auth required)
+ * GET /admin/doctor-claims — admin moderation queue
+ * POST /admin/doctor-claims/:id/decide — approve | reject (admin only)
+ *
+ * Auto-approval rules:
+ * - For target_professional_id: NPI matches AND email_verified_at set AND
+ * submitted email domain matches a known org-website domain for that pro.
+ * - For target_organization_id: email_verified_at set AND submitted email
+ * domain matches the org.website hostname.
+ * Otherwise queued for admin manual review.
+ */
+const express = require('express');
+const crypto = require('node:crypto');
+const { fetch } = require('undici');
+const { query } = require('../../shared/db');
+const { requireRole } = require('../auth');
+
+const router = express.Router();
+
+const GEORGE_BASE = process.env.GEORGE_BASE || 'http://127.0.0.1:9850';
+
+function token(n = 32) {
+ return crypto.randomBytes(n).toString('hex');
+}
+
+function emailDomain(e) {
+ const at = String(e || '').lastIndexOf('@');
+ return at >= 0 ? String(e).slice(at + 1).toLowerCase() : '';
+}
+
+function urlDomain(u) {
+ try { return new URL(/^https?:\/\//i.test(u) ? u : 'https://' + u).hostname.toLowerCase().replace(/^www\./, ''); }
+ catch { return ''; }
+}
+
+async function sendVerifyEmail(to, link, name) {
+ if (!process.env.GEORGE_DISABLE) {
+ try {
+ await fetch(`${GEORGE_BASE}/api/send`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ to,
+ subject: `Verify your claim on ${name || 'a healthcare profile'}`,
+ body: `Click to verify the claim:\n\n${link}\n\nIf you did not request this, ignore this email. The link expires in 7 days.\n\n— Agent Abrams`,
+ }),
+ signal: AbortSignal.timeout(10_000),
+ });
+ } catch (_) { /* George down — admin can re-send */ }
+ }
+}
+
+router.use((req, res, next) => {
+ if (!req.user) return res.status(401).json({ error: 'auth required' });
+ next();
+});
+
+// Submit a claim.
+router.post('/', async (req, res, next) => {
+ try {
+ const b = req.body || {};
+ const targetProId = b.target_professional_id ? Number(b.target_professional_id) : null;
+ const targetOrgId = b.target_organization_id ? Number(b.target_organization_id) : null;
+ const npi = b.npi ? String(b.npi).slice(0, 10) : null;
+ const license = b.license_number ? String(b.license_number).slice(0, 64) : null;
+
+ if (Boolean(targetProId) === Boolean(targetOrgId)) {
+ return res.status(400).json({ error: 'must provide exactly one of target_professional_id or target_organization_id' });
+ }
+
+ // Pull verification context.
+ let row = null;
+ if (targetProId) {
+ row = (await query('SELECT id, full_name, npi_number, license_number FROM professionals WHERE id = $1 AND opted_out = false', [targetProId])).rows[0];
+ } else {
+ row = (await query('SELECT id, name, website FROM organizations WHERE id = $1 AND opted_out = false', [targetOrgId])).rows[0];
+ }
+ if (!row) return res.status(404).json({ error: 'target not found' });
+
+ // Block second pending claim for same target.
+ const existing = await query(
+ `SELECT id FROM doctor_claims
+ WHERE status = 'pending' AND
+ ((target_professional_id = $1 AND $1 IS NOT NULL) OR
+ (target_organization_id = $2 AND $2 IS NOT NULL))
+ LIMIT 1`,
+ [targetProId, targetOrgId]
+ );
+ if (existing.rowCount > 0) return res.status(409).json({ error: 'a pending claim already exists for this target' });
+
+ const verifyToken = token();
+ const r = await query(`
+ INSERT INTO doctor_claims (user_id, target_professional_id, target_organization_id,
+ npi_provided, license_provided, email_verification_token, status)
+ VALUES ($1, $2, $3, $4, $5, $6, 'pending')
+ RETURNING id, email_verification_token, created_at
+ `, [req.user.id, targetProId, targetOrgId, npi, license, verifyToken]);
+
+ const link = `${req.protocol}://${req.get('host')}/doctor-claims/${verifyToken}`;
+ const name = row.full_name || row.name || '';
+ await sendVerifyEmail(req.user.email, link, name);
+
+ res.json({ ok: true, claim_id: r.rows[0].id, verify_link_sent_to: req.user.email });
+ } catch (e) { next(e); }
+});
+
+// My claims. Defined BEFORE /:token so 'mine' isn't captured as a token.
+router.get('/mine', async (req, res, next) => {
+ try {
+ const r = await query(`
+ SELECT id, target_professional_id, target_organization_id,
+ status, status_reason, email_verified_at, created_at, decided_at
+ FROM doctor_claims
+ WHERE user_id = $1
+ ORDER BY id DESC LIMIT 50
+ `, [req.user.id]);
+ res.json({ count: r.rowCount, rows: r.rows });
+ } catch (e) { next(e); }
+});
+
+// Email-link verify. Open in browser; sets email_verified_at and runs auto-approval.
+router.get('/:token', async (req, res, next) => {
+ try {
+ const t = String(req.params.token || '');
+ if (!/^[a-f0-9]{32,128}$/i.test(t)) return res.status(400).type('text').send('invalid token');
+
+ const claim = (await query(
+ `SELECT * FROM doctor_claims WHERE email_verification_token = $1 LIMIT 1`,
+ [t]
+ )).rows[0];
+ if (!claim) return res.status(404).type('text').send('claim not found');
+ if (claim.status !== 'pending') return res.status(409).type('text').send(`claim already ${claim.status}`);
+ if (claim.user_id !== req.user.id) return res.status(403).type('text').send('this verify link is for a different user');
+
+ await query(`UPDATE doctor_claims SET email_verified_at = now() WHERE id = $1`, [claim.id]);
+
+ // Auto-approval check.
+ let autoApprove = false;
+ let reason = '';
+ const userDomain = emailDomain(req.user.email);
+
+ if (claim.target_professional_id) {
+ const pro = (await query('SELECT npi_number FROM professionals WHERE id=$1', [claim.target_professional_id])).rows[0];
+ if (pro && pro.npi_number && claim.npi_provided && pro.npi_number === claim.npi_provided) {
+ // Look up the org website domain via professional_locations.
+ const orgWeb = (await query(`
+ SELECT DISTINCT o.website
+ FROM professional_locations pl
+ JOIN organizations o ON o.id = pl.organization_id
+ WHERE pl.professional_id = $1 AND o.website IS NOT NULL`,
+ [claim.target_professional_id])).rows.map(r => urlDomain(r.website)).filter(Boolean);
+ if (orgWeb.includes(userDomain)) {
+ autoApprove = true;
+ reason = 'NPI match + email domain matches practice website';
+ }
+ }
+ } else if (claim.target_organization_id) {
+ const org = (await query('SELECT website FROM organizations WHERE id=$1', [claim.target_organization_id])).rows[0];
+ if (org && org.website) {
+ const ow = urlDomain(org.website);
+ // SECURITY: codex flagged that string-suffix matching lets attacker@example.com
+ // claim *.example.com practices. Require strict equality, OR exact subdomain
+ // (the user's domain ends with `.<orgdomain>`) — not the reverse.
+ // Also: queue for admin review when there's any ambiguity.
+ if (ow && ow === userDomain) {
+ autoApprove = true;
+ reason = 'email domain exactly matches org website';
+ }
+ }
+ }
+
+ if (autoApprove) {
+ await query(`UPDATE doctor_claims SET status='approved', status_reason=$2, decided_at=now() WHERE id=$1`, [claim.id, reason]);
+ // Promote user role + claim link.
+ const newRole = claim.target_professional_id ? 'doctor' : 'doctor';
+ await query(`
+ UPDATE users
+ SET role = $1,
+ claimed_professional_id = COALESCE($2, claimed_professional_id),
+ claimed_organization_id = COALESCE($3, claimed_organization_id)
+ WHERE id = $4
+ `, [newRole, claim.target_professional_id, claim.target_organization_id, claim.user_id]);
+ // Flip target's claim_status.
+ if (claim.target_professional_id) {
+ await query(`UPDATE professionals SET claim_status='verified' WHERE id=$1`, [claim.target_professional_id]);
+ } else {
+ await query(`UPDATE organizations SET claim_status='verified' WHERE id=$1`, [claim.target_organization_id]);
+ }
+ return res.type('html').send(`<h2>Claim approved</h2><p>${reason}.</p><p>You're now signed in as a <strong>doctor</strong>. <a href="/">Continue</a>.</p>`);
+ }
+
+ res.type('html').send(`<h2>Email verified</h2><p>Your claim is now in admin review. We'll email you when it's decided.</p><p><a href="/">Back</a></p>`);
+ } catch (e) { next(e); }
+});
+
+// ─── /admin/doctor-claims ─────────────────────────────────────────────────
+const adminRouter = express.Router();
+adminRouter.use(requireRole('admin'));
+
+adminRouter.get('/', async (req, res, next) => {
+ try {
+ const status = req.query.status || 'pending';
+ const r = await query(`
+ SELECT c.*, u.email AS user_email,
+ COALESCE(p.full_name, o.name) AS target_name,
+ p.npi_number AS target_npi,
+ o.website AS target_website
+ FROM doctor_claims c
+ JOIN users u ON u.id = c.user_id
+ LEFT JOIN professionals p ON p.id = c.target_professional_id
+ LEFT JOIN organizations o ON o.id = c.target_organization_id
+ WHERE c.status = $1
+ ORDER BY c.id DESC LIMIT 200
+ `, [status]);
+ res.json({ count: r.rowCount, rows: r.rows });
+ } catch (e) { next(e); }
+});
+
+adminRouter.post('/:id/decide', async (req, res, next) => {
+ try {
+ const id = Number(req.params.id);
+ const decision = String(req.body?.decision || '').toLowerCase();
+ const note = String(req.body?.note || '').slice(0, 500);
+ if (!['approve', 'reject'].includes(decision)) return res.status(400).json({ error: "decision must be 'approve' or 'reject'" });
+
+ const claim = (await query('SELECT * FROM doctor_claims WHERE id = $1 FOR UPDATE', [id])).rows[0];
+ if (!claim) return res.status(404).json({ error: 'not found' });
+ if (claim.status !== 'pending') return res.status(409).json({ error: `already ${claim.status}` });
+
+ if (decision === 'approve') {
+ await query(`UPDATE doctor_claims SET status='approved', status_reason=$2, decided_at=now(), decided_by_user_id=$3 WHERE id=$1`,
+ [id, note || 'admin approved', req.user.id]);
+ await query(`UPDATE users SET role='doctor',
+ claimed_professional_id = COALESCE($2, claimed_professional_id),
+ claimed_organization_id = COALESCE($3, claimed_organization_id)
+ WHERE id = $1`,
+ [claim.user_id, claim.target_professional_id, claim.target_organization_id]);
+ if (claim.target_professional_id) {
+ await query(`UPDATE professionals SET claim_status='verified' WHERE id=$1`, [claim.target_professional_id]);
+ } else {
+ await query(`UPDATE organizations SET claim_status='verified' WHERE id=$1`, [claim.target_organization_id]);
+ }
+ } else {
+ await query(`UPDATE doctor_claims SET status='rejected', status_reason=$2, decided_at=now(), decided_by_user_id=$3 WHERE id=$1`,
+ [id, note || 'admin rejected', req.user.id]);
+ }
+ res.json({ ok: true, claim_id: id, decision });
+ } catch (e) { next(e); }
+});
+
+module.exports = { router, adminRouter };
diff --git a/agents/api-agent/routes/messages.js b/agents/api-agent/routes/messages.js
new file mode 100644
index 0000000..45dcad0
--- /dev/null
+++ b/agents/api-agent/routes/messages.js
@@ -0,0 +1,104 @@
+/**
+ * /messages — direct messages between any two patients/doctors.
+ *
+ * GET /messages — my DM pair index
+ * GET /messages/:pairId — message history
+ * POST /messages/:pairId/read — mark all incoming as read
+ * POST /messages — send (paid tier required)
+ *
+ * Disclaimer banner enforced client-side; server-side adds the
+ * "not medical advice" prefix to every outgoing message body.
+ */
+const express = require('express');
+const { query } = require('../../shared/db');
+const { requireTier } = require('../auth');
+
+const router = express.Router();
+
+router.use((req, res, next) => {
+ if (!req.user) return res.status(401).json({ error: 'auth required' });
+ next();
+});
+
+// GET /messages — list my DM pairs with last-message preview + unread count.
+router.get('/', async (req, res, next) => {
+ try {
+ const me = req.user.id;
+ const r = await query(`
+ SELECT p.id, p.user_a_id, p.user_b_id, p.last_message_at,
+ other.display_name AS other_display, other.role AS other_role,
+ other.id AS other_id,
+ (SELECT body FROM messages m WHERE m.dm_pair_id = p.id ORDER BY m.sent_at DESC LIMIT 1) AS last_body,
+ (SELECT COUNT(*) FROM messages m WHERE m.dm_pair_id = p.id AND m.author_user_id <> $1 AND m.read_at IS NULL) AS unread
+ FROM dm_pairs p
+ JOIN users other ON other.id = (CASE WHEN p.user_a_id = $1 THEN p.user_b_id ELSE p.user_a_id END)
+ WHERE p.user_a_id = $1 OR p.user_b_id = $1
+ ORDER BY p.last_message_at DESC NULLS LAST
+ LIMIT 100`, [me]);
+ res.json({ count: r.rowCount, rows: r.rows });
+ } catch (e) { next(e); }
+});
+
+// GET /messages/:pairId — message history.
+router.get('/:pairId', async (req, res, next) => {
+ try {
+ const pid = Number(req.params.pairId);
+ const me = req.user.id;
+ const pair = (await query(`SELECT * FROM dm_pairs WHERE id=$1`, [pid])).rows[0];
+ if (!pair || (pair.user_a_id != me && pair.user_b_id != me && req.user.role !== 'admin')) {
+ return res.status(404).json({ error: 'not found' });
+ }
+ const msgs = (await query(`
+ SELECT id, author_user_id, body, sent_at, read_at
+ FROM messages WHERE dm_pair_id = $1 ORDER BY sent_at LIMIT 500`, [pid])).rows;
+ res.json({ pair, messages: msgs });
+ } catch (e) { next(e); }
+});
+
+// POST /messages — send a DM. body: { to_user_id, body }
+router.post('/', requireTier('paid'), async (req, res, next) => {
+ try {
+ const toUser = Number(req.body?.to_user_id);
+ const myId = Number(req.user.id); // PG bigint comes back as string
+ const body = String(req.body?.body || '').trim();
+ if (!toUser || toUser === myId) return res.status(400).json({ error: 'invalid to_user_id (cannot DM yourself)' });
+ if (body.length < 1 || body.length > 5000) return res.status(400).json({ error: 'body 1-5000 chars' });
+
+ // Verify target user exists and isn't deleted.
+ const u = (await query(`SELECT id, role, deleted_at FROM users WHERE id=$1`, [toUser])).rows[0];
+ if (!u || u.deleted_at) return res.status(404).json({ error: 'recipient not found' });
+
+ // Canonical pair: smaller id first.
+ const aId = Math.min(myId, toUser);
+ const bId = Math.max(myId, toUser);
+ let pair = (await query(`SELECT id FROM dm_pairs WHERE user_a_id=$1 AND user_b_id=$2`, [aId, bId])).rows[0];
+ if (!pair) {
+ pair = (await query(`INSERT INTO dm_pairs (user_a_id, user_b_id) VALUES ($1, $2) RETURNING id`, [aId, bId])).rows[0];
+ }
+
+ // Server-side disclaimer prefix on every message — defensive.
+ const safeBody = body.startsWith('[Not medical advice]')
+ ? body
+ : '[Not medical advice — for clinical questions, call the office.]\n\n' + body;
+
+ const m = await query(`
+ INSERT INTO messages (dm_pair_id, author_user_id, body) VALUES ($1, $2, $3)
+ RETURNING id, sent_at`, [pair.id, req.user.id, safeBody]);
+
+ res.json({ ok: true, message_id: m.rows[0].id, dm_pair_id: pair.id });
+ } catch (e) { next(e); }
+});
+
+// POST /messages/:pairId/read — mark incoming as read.
+router.post('/:pairId/read', async (req, res, next) => {
+ try {
+ const pid = Number(req.params.pairId);
+ await query(`
+ UPDATE messages SET read_at = now()
+ WHERE dm_pair_id = $1 AND author_user_id <> $2 AND read_at IS NULL`,
+ [pid, req.user.id]);
+ res.json({ ok: true });
+ } catch (e) { next(e); }
+});
+
+module.exports = router;
diff --git a/agents/api-agent/routes/reviews.js b/agents/api-agent/routes/reviews.js
new file mode 100644
index 0000000..c6d31e9
--- /dev/null
+++ b/agents/api-agent/routes/reviews.js
@@ -0,0 +1,211 @@
+/**
+ * /reviews — patient + community reviews of professionals + organizations.
+ *
+ * GET /reviews?target_org=X|target_pro=X — public list (honors suppression + hidden + 24h delay)
+ * POST /reviews — submit (auth required)
+ * POST /reviews/:id/respond — doctor's reply (claimed-doctor only)
+ * POST /reviews/:id/flag — community moderation
+ * POST /reviews/:id/suppress — claimed target hides it from their profile
+ *
+ * Admin moderation lives in /admin/reviews/* (separate router).
+ *
+ * Anti-brigading: user-source reviews go live 24h after creation (go_live_at).
+ * Seeded reviews go live immediately.
+ */
+const express = require('express');
+const { query } = require('../../shared/db');
+const { requireRole } = require('../auth');
+
+const router = express.Router();
+
+// PUBLIC: list reviews for a target. Filters out suppressed/hidden/not-yet-live.
+router.get('/', async (req, res, next) => {
+ try {
+ const proId = req.query.target_pro ? Number(req.query.target_pro) : null;
+ const orgId = req.query.target_org ? Number(req.query.target_org) : null;
+ if (!proId && !orgId) return res.status(400).json({ error: 'target_pro or target_org required' });
+
+ const limit = Math.min(Number(req.query.limit) || 25, 100);
+ const offset = Number(req.query.offset) || 0;
+
+ const where = [
+ 'r.suppressed_by_target = false',
+ 'r.hidden_by_admin = false',
+ "(r.go_live_at IS NULL OR r.go_live_at <= now())",
+ ];
+ const params = [];
+ if (proId) { params.push(proId); where.push(`r.target_professional_id = $${params.length}`); }
+ if (orgId) { params.push(orgId); where.push(`r.target_organization_id = $${params.length}`); }
+
+ const r = await query(`
+ SELECT r.id, r.target_professional_id, r.target_organization_id,
+ r.service_score, r.price_score, r.quality_score, r.overall_score,
+ r.title, r.body, r.source, r.source_url, r.source_posted_at,
+ r.created_at,
+ COALESCE(r.reviewer_display, u.display_name, 'Patient') AS author_name,
+ rr.id AS response_id, rr.body AS response_body, rr.created_at AS response_at
+ FROM reviews r
+ LEFT JOIN users u ON u.id = r.reviewer_user_id
+ LEFT JOIN review_responses rr ON rr.review_id = r.id
+ WHERE ${where.join(' AND ')}
+ ORDER BY r.created_at DESC
+ LIMIT ${limit} OFFSET ${offset}
+ `, params);
+ res.json({ count: r.rowCount, rows: r.rows });
+ } catch (e) { next(e); }
+});
+
+// AGGREGATE — per-target denormalized scores, fast lookup.
+router.get('/summary', async (req, res, next) => {
+ try {
+ const proId = req.query.target_pro ? Number(req.query.target_pro) : null;
+ const orgId = req.query.target_org ? Number(req.query.target_org) : null;
+ if (!proId && !orgId) return res.status(400).json({ error: 'target_pro or target_org required' });
+ const r = await query(`
+ SELECT * FROM aggregated_ratings
+ WHERE ($1::bigint IS NOT NULL AND target_professional_id = $1)
+ OR ($2::bigint IS NOT NULL AND target_organization_id = $2)
+ LIMIT 1`,
+ [proId, orgId]);
+ res.json(r.rows[0] || { n_reviews: 0, overall_avg: null });
+ } catch (e) { next(e); }
+});
+
+// AUTHED routes below.
+router.use((req, res, next) => {
+ if (!req.user) return res.status(401).json({ error: 'auth required' });
+ next();
+});
+
+// POST /reviews — submit a new user review.
+router.post('/', async (req, res, next) => {
+ try {
+ const b = req.body || {};
+ const proId = b.target_professional_id ? Number(b.target_professional_id) : null;
+ const orgId = b.target_organization_id ? Number(b.target_organization_id) : null;
+ if (Boolean(proId) === Boolean(orgId)) {
+ return res.status(400).json({ error: 'must provide exactly one of target_professional_id or target_organization_id' });
+ }
+
+ // Block reviewing your own claimed entity.
+ if ((proId && req.user.claimed_professional_id === proId) ||
+ (orgId && req.user.claimed_organization_id === orgId)) {
+ return res.status(403).json({ error: 'cannot review your own claimed profile' });
+ }
+
+ const body = String(b.body || '').trim();
+ if (body.length < 10 || body.length > 5000) return res.status(400).json({ error: 'body 10-5000 chars' });
+ const title = b.title ? String(b.title).slice(0, 200) : null;
+ const sv = b.service_score != null ? Math.max(1, Math.min(5, Math.round(Number(b.service_score)))) : null;
+ const pr = b.price_score != null ? Math.max(1, Math.min(5, Math.round(Number(b.price_score)))) : null;
+ const ql = b.quality_score != null ? Math.max(1, Math.min(5, Math.round(Number(b.quality_score)))) : null;
+ const ov = (sv != null && pr != null && ql != null) ? Math.round((sv + pr + ql) / 3) : (b.overall_score != null ? Math.max(1, Math.min(5, Math.round(Number(b.overall_score)))) : null);
+
+ const r = await query(`
+ INSERT INTO reviews (target_professional_id, target_organization_id, reviewer_user_id,
+ service_score, price_score, quality_score, overall_score,
+ title, body, source, go_live_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'user', now() + interval '24 hours')
+ RETURNING id, go_live_at
+ `, [proId, orgId, req.user.id, sv, pr, ql, ov, title, body]);
+ res.json({ ok: true, review_id: r.rows[0].id, go_live_at: r.rows[0].go_live_at, note: 'visible publicly in 24 hours (anti-brigading delay)' });
+ } catch (e) {
+ if (e.code === '23505') return res.status(409).json({ error: 'you already reviewed this target' });
+ next(e);
+ }
+});
+
+// POST /reviews/:id/respond — doctor's official reply on a review of THEIR claimed target.
+router.post('/:id/respond', async (req, res, next) => {
+ try {
+ const id = Number(req.params.id);
+ const body = String(req.body?.body || '').trim();
+ if (body.length < 10 || body.length > 3000) return res.status(400).json({ error: 'body 10-3000 chars' });
+ const r = (await query(`SELECT target_professional_id, target_organization_id FROM reviews WHERE id=$1`, [id])).rows[0];
+ if (!r) return res.status(404).json({ error: 'review not found' });
+ const claimedPro = req.user.claimed_professional_id;
+ const claimedOrg = req.user.claimed_organization_id;
+ const ownsTarget = (r.target_professional_id && r.target_professional_id == claimedPro) ||
+ (r.target_organization_id && r.target_organization_id == claimedOrg);
+ if (!ownsTarget && req.user.role !== 'admin') {
+ return res.status(403).json({ error: 'only the claimed practitioner/practice or an admin may respond' });
+ }
+ if (req.user.role !== 'admin' && req.user.tier !== 'paid') {
+ return res.status(402).json({ error: 'doctor responses require paid tier', upgrade: '/subscriptions/checkout' });
+ }
+ const ins = await query(`
+ INSERT INTO review_responses (review_id, author_user_id, body)
+ VALUES ($1, $2, $3)
+ ON CONFLICT (review_id) DO UPDATE SET body = EXCLUDED.body, updated_at = now()
+ RETURNING id, created_at, updated_at`, [id, req.user.id, body]);
+ res.json({ ok: true, response: ins.rows[0] });
+ } catch (e) { next(e); }
+});
+
+// POST /reviews/:id/flag — any authed user
+router.post('/:id/flag', async (req, res, next) => {
+ try {
+ const id = Number(req.params.id);
+ const reason = String(req.body?.reason || '').toLowerCase();
+ const allowed = ['spam','fake','off_topic','medical_advice','personal_attack','other'];
+ if (!allowed.includes(reason)) return res.status(400).json({ error: 'reason must be one of: ' + allowed.join(', ') });
+ const notes = String(req.body?.notes || '').slice(0, 500);
+ await query(`INSERT INTO review_flags (review_id, flagger_user_id, reason, notes) VALUES ($1, $2, $3, $4)`,
+ [id, req.user.id, reason, notes]);
+ res.json({ ok: true });
+ } catch (e) { next(e); }
+});
+
+// POST /reviews/:id/suppress — only the claimed-target user
+// (suppress = hide on MY profile; review still exists, admin can see).
+router.post('/:id/suppress', async (req, res, next) => {
+ try {
+ const id = Number(req.params.id);
+ const r = (await query(`SELECT target_professional_id, target_organization_id FROM reviews WHERE id=$1`, [id])).rows[0];
+ if (!r) return res.status(404).json({ error: 'not found' });
+ const claimedPro = req.user.claimed_professional_id;
+ const claimedOrg = req.user.claimed_organization_id;
+ const ownsTarget = (r.target_professional_id && r.target_professional_id == claimedPro) ||
+ (r.target_organization_id && r.target_organization_id == claimedOrg);
+ if (!ownsTarget && req.user.role !== 'admin') {
+ return res.status(403).json({ error: 'only the claimed target may suppress reviews on their own profile' });
+ }
+ const flag = req.body?.suppress !== false;
+ await query(`UPDATE reviews SET suppressed_by_target=$2, updated_at=now() WHERE id=$1`, [id, flag]);
+ res.json({ ok: true, suppressed: flag });
+ } catch (e) { next(e); }
+});
+
+// ─── /admin/reviews moderation queue ──────────────────────────────────────
+const adminRouter = express.Router();
+adminRouter.use(requireRole('admin'));
+
+adminRouter.get('/queue', async (req, res, next) => {
+ try {
+ const r = await query(`
+ SELECT r.*, COUNT(rf.id) AS flag_count,
+ ARRAY_AGG(DISTINCT rf.reason) FILTER (WHERE rf.reason IS NOT NULL) AS flag_reasons
+ FROM reviews r
+ LEFT JOIN review_flags rf ON rf.review_id = r.id
+ WHERE r.hidden_by_admin = false
+ AND (rf.id IS NOT NULL OR r.go_live_at > now())
+ GROUP BY r.id
+ ORDER BY COUNT(rf.id) DESC, r.created_at DESC
+ LIMIT 200`);
+ res.json({ count: r.rowCount, rows: r.rows });
+ } catch (e) { next(e); }
+});
+
+adminRouter.post('/:id/decide', async (req, res, next) => {
+ try {
+ const id = Number(req.params.id);
+ const decision = String(req.body?.decision || '').toLowerCase();
+ const reason = String(req.body?.reason || '').slice(0, 500);
+ if (!['hide','restore'].includes(decision)) return res.status(400).json({ error: "decision must be 'hide' or 'restore'" });
+ const flag = decision === 'hide';
+ await query(`UPDATE reviews SET hidden_by_admin=$2, hidden_reason=$3, updated_at=now() WHERE id=$1`, [id, flag, reason || null]);
+ res.json({ ok: true, hidden: flag });
+ } catch (e) { next(e); }
+});
+
+module.exports = { router, adminRouter };
diff --git a/agents/api-agent/routes/subscriptions.js b/agents/api-agent/routes/subscriptions.js
new file mode 100644
index 0000000..07f5d1d
--- /dev/null
+++ b/agents/api-agent/routes/subscriptions.js
@@ -0,0 +1,96 @@
+/**
+ * /subscriptions — Stripe Checkout for paid tier upgrades.
+ *
+ * POST /subscriptions/checkout — { plan } → returns { url } for hosted Checkout
+ * GET /subscriptions/mine — current user's subscriptions
+ *
+ * Two products:
+ * - doctor_pro_monthly → STRIPE_PRICE_DOCTOR_PRO env var
+ * - patient_plus_monthly → STRIPE_PRICE_PATIENT_PLUS env var
+ *
+ * Webhook handling lives in data_market.js (extended) — that file already
+ * verifies signatures. Sub events upsert into the subscriptions table and
+ * flip users.tier accordingly.
+ */
+const express = require('express');
+const Stripe = require('stripe');
+const { query } = require('../../shared/db');
+
+const router = express.Router();
+
+const STRIPE_KEY = process.env.STRIPE_SECRET_KEY || '';
+const stripe = STRIPE_KEY ? new Stripe(STRIPE_KEY) : null;
+
+const PLAN_PRICES = {
+ doctor_pro_monthly: process.env.STRIPE_PRICE_DOCTOR_PRO || '',
+ patient_plus_monthly: process.env.STRIPE_PRICE_PATIENT_PLUS || '',
+};
+
+router.use((req, res, next) => {
+ if (!req.user) return res.status(401).json({ error: 'auth required' });
+ if (!stripe) return res.status(503).json({ error: 'Stripe not configured (set STRIPE_SECRET_KEY + STRIPE_PRICE_DOCTOR_PRO + STRIPE_PRICE_PATIENT_PLUS)' });
+ next();
+});
+
+router.post('/checkout', async (req, res, next) => {
+ try {
+ const plan = String(req.body?.plan || '');
+ const priceId = PLAN_PRICES[plan];
+ if (!priceId) return res.status(400).json({ error: 'unknown plan; allowed: ' + Object.keys(PLAN_PRICES).join(', ') });
+
+ // Doctor pro plan only available to verified-doctor accounts.
+ if (plan === 'doctor_pro_monthly' && req.user.role !== 'doctor' && req.user.role !== 'admin') {
+ return res.status(403).json({ error: 'doctor_pro_monthly requires a verified doctor account; submit a claim at /doctor-claims first' });
+ }
+
+ // Reuse existing Stripe customer or create one.
+ let customerId = req.user.stripe_customer_id;
+ if (!customerId) {
+ const c = await stripe.customers.create({
+ email: req.user.email,
+ metadata: { pd_user_id: String(req.user.id), pd_role: req.user.role },
+ });
+ customerId = c.id;
+ await query('UPDATE users SET stripe_customer_id = $1 WHERE id = $2', [customerId, req.user.id]);
+ }
+
+ const session = await stripe.checkout.sessions.create({
+ mode: 'subscription',
+ customer: customerId,
+ line_items: [{ price: priceId, quantity: 1 }],
+ success_url: `${req.protocol}://${req.get('host')}/?subscribed=${plan}`,
+ cancel_url: `${req.protocol}://${req.get('host')}/?cancelled=1`,
+ allow_promotion_codes: true,
+ metadata: { pd_user_id: String(req.user.id), plan },
+ });
+
+ res.json({ url: session.url, plan });
+ } catch (e) { next(e); }
+});
+
+router.get('/mine', async (req, res, next) => {
+ try {
+ const r = await query(`
+ SELECT id, stripe_subscription_id, plan, status, current_period_end,
+ cancel_at_period_end, started_at, ended_at
+ FROM subscriptions
+ WHERE user_id = $1
+ ORDER BY id DESC
+ `, [req.user.id]);
+ res.json({ count: r.rowCount, rows: r.rows, current_tier: req.user.tier });
+ } catch (e) { next(e); }
+});
+
+// Stripe Billing Portal redirect (manage card, cancel, etc).
+router.get('/portal', async (req, res, next) => {
+ try {
+ if (!req.user.stripe_customer_id) return res.status(400).json({ error: 'no Stripe customer on file' });
+ const portal = await stripe.billingPortal.sessions.create({
+ customer: req.user.stripe_customer_id,
+ return_url: `${req.protocol}://${req.get('host')}/`,
+ });
+ res.json({ url: portal.url });
+ } catch (e) { next(e); }
+});
+
+module.exports = router;
diff --git a/agents/api-agent/routes/threads.js b/agents/api-agent/routes/threads.js
new file mode 100644
index 0000000..7ee1091
--- /dev/null
+++ b/agents/api-agent/routes/threads.js
@@ -0,0 +1,118 @@
+/**
+ * /threads — community discussion threads.
+ *
+ * GET /threads — list (any user)
+ * GET /threads/:id — single thread + posts
+ * POST /threads — create (paid tier)
+ * POST /threads/:id/posts — reply (paid tier)
+ * POST /admin/threads/:id/hide — admin moderation
+ */
+const express = require('express');
+const { query } = require('../../shared/db');
+const { requireRole, requireTier } = require('../auth');
+
+const router = express.Router();
+
+router.get('/', async (req, res, next) => {
+ try {
+ const limit = Math.min(Number(req.query.limit) || 50, 100);
+ const cat = req.query.category ? String(req.query.category) : null;
+ const city = req.query.city ? String(req.query.city) : null;
+ const where = ['hidden_by_admin = false']; const params = [];
+ if (cat) { params.push(cat); where.push(`category = $${params.length}`); }
+ if (city) { params.push(city); where.push(`city ILIKE $${params.length}`); }
+ const r = await query(`
+ SELECT t.id, t.title, t.body, t.category, t.city, t.zip, t.organization_id,
+ t.reply_count, t.last_post_at, t.created_at,
+ u.display_name AS author, u.role AS author_role
+ FROM threads t
+ LEFT JOIN users u ON u.id = t.created_by_user_id
+ WHERE ${where.join(' AND ')}
+ ORDER BY t.pinned DESC, t.last_post_at DESC
+ LIMIT ${limit}
+ `, params);
+ res.json({ count: r.rowCount, rows: r.rows });
+ } catch (e) { next(e); }
+});
+
+router.get('/:id', async (req, res, next) => {
+ try {
+ const id = Number(req.params.id);
+ const t = (await query(`
+ SELECT t.*, u.display_name AS author, u.role AS author_role
+ FROM threads t
+ LEFT JOIN users u ON u.id = t.created_by_user_id
+ WHERE t.id = $1 AND t.hidden_by_admin = false`, [id])).rows[0];
+ if (!t) return res.status(404).json({ error: 'not found' });
+ const posts = (await query(`
+ SELECT p.id, p.body, p.created_at,
+ u.display_name AS author, u.role AS author_role
+ FROM thread_posts p
+ LEFT JOIN users u ON u.id = p.author_user_id
+ WHERE p.thread_id = $1 AND p.hidden_by_admin = false
+ ORDER BY p.created_at`, [id])).rows;
+ res.json({ thread: t, posts });
+ } catch (e) { next(e); }
+});
+
+// Authed write routes.
+router.use((req, res, next) => {
+ if (!req.user) return res.status(401).json({ error: 'auth required' });
+ next();
+});
+
+router.post('/', requireTier('paid'), async (req, res, next) => {
+ try {
+ const b = req.body || {};
+ const title = String(b.title || '').trim().slice(0, 250);
+ const body = String(b.body || '').trim().slice(0, 10_000);
+ if (title.length < 5) return res.status(400).json({ error: 'title 5+ chars' });
+ const cat = ['general','recommendations','specialty','insurance','pediatrics','eldercare','mental_health','dental','optometry','question','rant'].includes(b.category) ? b.category : 'general';
+ const city = b.city ? String(b.city).slice(0, 80) : null;
+ const orgId = b.organization_id ? Number(b.organization_id) : null;
+ const r = await query(`
+ INSERT INTO threads (title, body, city, organization_id, category, created_by_user_id)
+ VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, created_at`,
+ [title, body || null, city, orgId, cat, req.user.id]);
+ res.json({ ok: true, thread_id: r.rows[0].id });
+ } catch (e) { next(e); }
+});
+
+router.post('/:id/posts', requireTier('paid'), async (req, res, next) => {
+ try {
+ const id = Number(req.params.id);
+ const body = String(req.body?.body || '').trim();
+ if (body.length < 2 || body.length > 10_000) return res.status(400).json({ error: 'body 2-10000 chars' });
+ const t = (await query(`SELECT id FROM threads WHERE id=$1 AND hidden_by_admin=false`, [id])).rows[0];
+ if (!t) return res.status(404).json({ error: 'thread not found' });
+ const r = await query(`
+ INSERT INTO thread_posts (thread_id, author_user_id, body)
+ VALUES ($1, $2, $3) RETURNING id, created_at`,
+ [id, req.user.id, body]);
+ res.json({ ok: true, post_id: r.rows[0].id });
+ } catch (e) { next(e); }
+});
+
+// ─── /admin/threads moderation ─────────────────────────────────────────────
+const adminRouter = express.Router();
+adminRouter.use(requireRole('admin'));
+
+adminRouter.post('/:id/hide', async (req, res, next) => {
+ try {
+ const id = Number(req.params.id);
+ const flag = req.body?.hidden !== false;
+ await query(`UPDATE threads SET hidden_by_admin=$2, updated_at=now() WHERE id=$1`, [id, flag]);
+ res.json({ ok: true, hidden: flag });
+ } catch (e) { next(e); }
+});
+
+adminRouter.post('/posts/:id/hide', async (req, res, next) => {
+ try {
+ const id = Number(req.params.id);
+ const flag = req.body?.hidden !== false;
+ await query(`UPDATE thread_posts SET hidden_by_admin=$2, updated_at=now() WHERE id=$1`, [id, flag]);
+ res.json({ ok: true, hidden: flag });
+ } catch (e) { next(e); }
+});
+
+module.exports = { router, adminRouter };
diff --git a/agents/api-agent/server.js b/agents/api-agent/server.js
index f4fa4ee..9e4beef 100644
--- a/agents/api-agent/server.js
+++ b/agents/api-agent/server.js
@@ -21,13 +21,92 @@ const path = require('path');
const fs = require('fs');
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
const express = require('express');
+const session = require('express-session');
+const PgSession = require('connect-pg-simple')(session);
const { stringify } = require('csv-stringify');
const { pool, query } = require('../shared/db');
+const { passport, requireRole, loopbackSyntheticAdmin, authConfigured } = require('./auth');
+const authRouter = require('./routes/auth');
+const claimsRouters = require('./routes/claims');
+const subscriptionsRouter = require('./routes/subscriptions');
+const chatRouter = require('./routes/chat');
+const reviewsRouters = require('./routes/reviews');
+const threadsRouters = require('./routes/threads');
+const messagesRouter = require('./routes/messages');
+const adminPitchRouter = require('./routes/admin-pitch');
+const dataMarketRouter = require('./data_market');
+const { stripeWebhookHandler } = dataMarketRouter;
+// Cycle-2 Claude review P1: defensive assertion. The module.exports pattern
+// in data_market.js is `module.exports = router; module.exports.fn = fn`.
+// If the export is ever lost in a refactor, server.js would silently mount
+// `app.post(path, raw, undefined)` and Stripe webhooks would 404 in prod.
+if (typeof stripeWebhookHandler !== 'function') {
+ throw new Error('[pd-api] FATAL: stripeWebhookHandler not exported from data_market — webhook would 404 in prod');
+}
const PORT = Number(process.env.PORT_API || 9874);
const app = express();
+
+// Security headers — added 2026-05-04 (overnight YOLO loop tick 26).
+// CSP disabled because /preview pages embed inline mockup HTML. Defer
+// CSP hardening per-route. Other Helmet headers (HSTS, X-Frame-Options,
+// X-Content-Type-Options, Referrer-Policy, etc.) all apply.
+const helmet = require('helmet');
+app.use(helmet({ contentSecurityPolicy: false }));
+
+// Codex P1 #1 fix: Stripe webhook MUST receive the raw request body so the
+// signature check can verify it. Mounted BEFORE express.json() — once the
+// global JSON parser runs, req.body is a parsed object and constructEvent()
+// rejects every event silently.
+app.post('/webhooks/stripe-data', express.raw({ type: 'application/json' }), stripeWebhookHandler);
+
app.use(express.json());
+// ─── Session + auth (Phase 1) ──────────────────────────────────────────────
+// Express-session backed by Postgres (user_sessions table created by 003).
+// SESSION_SECRET must be set in .env for prod; default placeholder here lets
+// loopback dev continue while we wait for Steve to mint one.
+app.set('trust proxy', 1); // pd-preview / Cloudflare tunnel adds X-Forwarded-For
+app.use(session({
+ store: new PgSession({ pool, tableName: 'user_sessions', createTableIfMissing: false }),
+ secret: process.env.SESSION_SECRET || 'pd-dev-only-replace-me',
+ resave: false,
+ saveUninitialized: false,
+ cookie: {
+ httpOnly: true,
+ sameSite: 'lax',
+ secure: process.env.NODE_ENV === 'production' && process.env.SESSION_INSECURE !== '1',
+ maxAge: 1000 * 60 * 60 * 24 * 30, // 30 days
+ },
+}));
+app.use(passport.initialize());
+app.use(passport.session());
+// Loopback synthetic-admin runs AFTER passport so it only fires when no real
+// session user exists. Gated by ADMIN_LOOPBACK_TRUST=1 in pm2 env.
+app.use(loopbackSyntheticAdmin);
+
+app.use('/auth', authRouter);
+app.use('/doctor-claims', claimsRouters.router);
+app.use('/admin/doctor-claims', claimsRouters.adminRouter);
+app.use('/subscriptions', subscriptionsRouter);
+app.use('/api/chat', chatRouter);
+app.use('/reviews', reviewsRouters.router);
+app.use('/admin/reviews', reviewsRouters.adminRouter);
+app.use('/threads', threadsRouters.router);
+app.use('/admin/threads', threadsRouters.adminRouter);
+app.use('/messages', messagesRouter);
+app.use('/admin/pitch', adminPitchRouter);
+
+// Static-serve generated mockups for the admin pitch flow.
+app.use('/mockups', express.static(path.resolve(__dirname, '../../data/mockups')));
+
+// Doctor data marketplace — mirrors Counsel & Bar's data_market for medical
+// recruiters / pharma / B2B. Mounted after the JSON parser so /data,
+// /data/checkout, etc. get parsed body. The /webhooks/stripe-data route
+// inside the router is now shadowed by the explicit raw mount above — that's
+// intentional, and works because Express resolves routes top-down.
+app.use(dataMarketRouter);
+
const OPT_OUT = ' AND opted_out = false ';
// ─── Dashboard ──────────────────────────────────────────────────────────────
@@ -58,17 +137,26 @@ app.get('/professionals', async (req, res, next) => {
if (min_confidence) { where.push(`p.source_confidence_score >= $${i}`); params.push(Number(min_confidence)); i++; }
const sql = `
- SELECT DISTINCT p.id, p.full_name, p.title, p.npi_number, p.license_number,
- p.license_status, p.primary_specialty, p.medical_school, p.graduation_year,
- p.source_confidence_score
- FROM professionals p
- LEFT JOIN professional_locations pl ON pl.professional_id = p.id
- LEFT JOIN organizations o ON o.id = pl.organization_id
- LEFT JOIN professional_specialties ps ON ps.professional_id = p.id
- LEFT JOIN specialties s ON s.id = ps.specialty_id
- WHERE ${where.join(' AND ')}
- ORDER BY p.source_confidence_score DESC NULLS LAST, p.full_name
- LIMIT ${limit} OFFSET ${offset}
+ WITH base AS (
+ SELECT DISTINCT p.id, p.full_name, p.title, p.npi_number, p.license_number,
+ p.license_status, p.primary_specialty, p.medical_school, p.graduation_year,
+ p.source_confidence_score
+ FROM professionals p
+ LEFT JOIN professional_locations pl ON pl.professional_id = p.id
+ LEFT JOIN organizations o ON o.id = pl.organization_id
+ LEFT JOIN professional_specialties ps ON ps.professional_id = p.id
+ LEFT JOIN specialties s ON s.id = ps.specialty_id
+ WHERE ${where.join(' AND ')}
+ ORDER BY p.source_confidence_score DESC NULLS LAST, p.full_name
+ LIMIT ${limit} OFFSET ${offset}
+ )
+ SELECT base.*,
+ (SELECT email FROM emails e
+ WHERE e.professional_id = base.id
+ ORDER BY e.last_verified_at DESC NULLS LAST, e.discovered_at DESC
+ LIMIT 1) AS email
+ FROM base
+ ORDER BY base.source_confidence_score DESC NULLS LAST, base.full_name
`;
const rows = (await query(sql, params)).rows;
res.json({ count: rows.length, rows });
@@ -77,8 +165,17 @@ app.get('/professionals', async (req, res, next) => {
app.get('/professionals/:npi', async (req, res, next) => {
try {
+ // SECURITY: explicit allow-list of public columns. Do NOT use p.* — internal
+ // columns like opted_out_at, dedup_hash, raw_json must not leak to API
+ // consumers. Add new columns here intentionally.
const r = await query(`
- SELECT p.*, json_agg(DISTINCT jsonb_build_object(
+ SELECT p.id, p.full_name, p.first_name, p.last_name, p.middle_name, p.suffix,
+ p.title, p.npi_number, p.license_number, p.license_type, p.license_status,
+ p.license_issue_date, p.license_expiration_date,
+ p.gender, p.medical_school, p.graduation_year, p.years_experience_estimate,
+ p.primary_specialty, p.secondary_specialties, p.bio, p.profile_image_url,
+ p.source_confidence_score, p.claim_status, p.created_at, p.updated_at,
+ json_agg(DISTINCT jsonb_build_object(
'organization_id', pl.organization_id,
'role', pl.role,
'address', pl.address,
@@ -102,23 +199,80 @@ app.get('/professionals/:npi', async (req, res, next) => {
} catch (e) { next(e); }
});
+// Category → type-bucket mapping (small-biz themes Steve sells websites to).
+const CATEGORY_TYPES = {
+ cosmetology: ['salon','barbershop','nail_salon','beauty_supply','spa'],
+ chiropractic: ['chiropractor_office'],
+ chinese_medicine:['acupuncture_clinic','tcm_clinic','tcm_herbalist'],
+ dental: ['dental_office'],
+ optometry: ['optometrist_office','optical_shop'],
+ fitness: ['yoga_studio','pilates_studio','gym'],
+ tattoo: ['tattoo_studio','piercing_studio'],
+ pets: ['vet_clinic','pet_grooming','pet_shop'],
+ medical: ['hospital','clinic','medical_group','surgery_center','urgent_care','fqhc'],
+};
+
// ─── Organizations ──────────────────────────────────────────────────────────
+function buildOrgWhere(qs) {
+ const where = [`opted_out = false`];
+ const params = []; let i = 1;
+ if (qs.type) { where.push(`type = $${i}`); params.push(qs.type); i++; }
+ if (qs.category && CATEGORY_TYPES[qs.category]) {
+ where.push(`type = ANY($${i}::text[])`); params.push(CATEGORY_TYPES[qs.category]); i++;
+ }
+ if (qs.city) { where.push(`city ILIKE $${i}`); params.push(qs.city); i++; }
+ if (qs.zip) { where.push(`zip = $${i}`); params.push(qs.zip); i++; }
+ if (qs.has_website === 'true') where.push(`website IS NOT NULL AND website <> ''`);
+ if (qs.has_website === 'false') where.push(`(website IS NULL OR website = '')`);
+ if (qs.has_phone === 'true') where.push(`phone IS NOT NULL AND phone <> ''`);
+ if (qs.has_phone === 'false') where.push(`(phone IS NULL OR phone = '')`);
+ if (qs.has_email === 'true') where.push(`EXISTS (SELECT 1 FROM emails e WHERE e.organization_id = organizations.id)`);
+ if (qs.has_email === 'false') where.push(`NOT EXISTS (SELECT 1 FROM emails e WHERE e.organization_id = organizations.id)`);
+ if (qs.domain_available === 'true') where.push(`domain_available = TRUE`);
+ if (qs.domain_available === 'false') where.push(`domain_available = FALSE`);
+ if (qs.starred === 'true') where.push(`starred = TRUE`);
+ if (qs.contacted === 'true') where.push(`contacted_at IS NOT NULL`);
+ if (qs.contacted === 'false') where.push(`contacted_at IS NULL`);
+ if (qs.is_chain === 'false') where.push(`is_chain = FALSE`);
+ if (qs.is_chain === 'true') where.push(`is_chain = TRUE`);
+ if (qs.min_score) {
+ where.push(`lead_score >= $${i}`); params.push(Number(qs.min_score)); i++;
+ }
+ return { where, params, nextParam: i };
+}
+
+function orderClause(sort) {
+ switch (sort) {
+ case 'score': return `ORDER BY lead_score DESC, name`;
+ case 'prospects': return `ORDER BY (website IS NULL OR website = '') DESC,
+ (phone IS NOT NULL AND phone <> '') DESC,
+ lead_score DESC, name`;
+ case 'has_website': return `ORDER BY (website IS NOT NULL AND website <> '') DESC, name`;
+ case 'zip': return `ORDER BY zip NULLS LAST, name`;
+ case 'name': return `ORDER BY name`;
+ default: return `ORDER BY lead_score DESC, name`;
+ }
+}
+
app.get('/organizations', async (req, res, next) => {
try {
- const { type, city, zip } = req.query;
const limit = Math.min(Number(req.query.limit) || 100, 500);
const offset = Number(req.query.offset) || 0;
- const where = [`opted_out = false`];
- const params = []; let i = 1;
- if (type) { where.push(`type = $${i}`); params.push(type); i++; }
- if (city) { where.push(`city ILIKE $${i}`); params.push(city); i++; }
- if (zip) { where.push(`zip = $${i}`); params.push(zip); i++; }
+ const { where, params } = buildOrgWhere(req.query);
const sql = `
SELECT id, name, type, address, city, state, zip, phone, website,
- lat, lng, hcai_id, cdph_license
+ lat, lng, hcai_id, cdph_license,
+ suggested_domain, domain_available, domain_checked_at,
+ starred, contacted_at, notes, is_chain, lead_score,
+ (website IS NOT NULL AND website <> '') AS has_website,
+ (phone IS NOT NULL AND phone <> '') AS has_phone,
+ (SELECT email FROM emails e
+ WHERE e.organization_id = organizations.id
+ ORDER BY e.last_verified_at DESC NULLS LAST, e.discovered_at DESC
+ LIMIT 1) AS email
FROM organizations
WHERE ${where.join(' AND ')}
- ORDER BY name
+ ${orderClause(req.query.sort)}
LIMIT ${limit} OFFSET ${offset}
`;
const rows = (await query(sql, params)).rows;
@@ -126,6 +280,162 @@ app.get('/organizations', async (req, res, next) => {
} catch (e) { next(e); }
});
+// Compact endpoint for the clustered map view — returns just the fields a pin
+// needs, for every org matching the filter. Capped at 80,000 rows.
+app.get('/map.json', async (req, res, next) => {
+ try {
+ const { where, params } = buildOrgWhere(req.query);
+ where.push('lat IS NOT NULL AND lng IS NOT NULL');
+ const sql = `
+ SELECT id, name, type, lat::float AS lat, lng::float AS lng,
+ (website IS NOT NULL AND website <> '') AS has_website,
+ EXISTS(SELECT 1 FROM emails e WHERE e.organization_id = organizations.id) AS has_email,
+ lead_score, contacted_at IS NOT NULL AS contacted
+ FROM organizations
+ WHERE ${where.join(' AND ')}
+ LIMIT 80000
+ `;
+ const rows = (await query(sql, params)).rows;
+ res.setHeader('Cache-Control', 'private, max-age=60');
+ res.json({ count: rows.length, rows });
+ } catch (e) { next(e); }
+});
+
+// PATCH-style update for sales-workflow fields. Body: { starred, contacted, notes }
+// SECURITY: codex flagged this as unauth + reachable when proxied; gate on admin
+// (synthetic-loopback admin counts; real OAuth admin counts; no one else).
+app.post('/organizations/:id/touch', requireRole('admin'), async (req, res, next) => {
+ try {
+ const id = Number(req.params.id);
+ if (!id) return res.status(400).json({ error: 'id required' });
+ const { starred, contacted, notes } = req.body || {};
+ const sets = []; const params = [id]; let i = 2;
+ if (typeof starred === 'boolean') { sets.push(`starred = $${i++}`); params.push(starred); }
+ if (contacted === true) { sets.push(`contacted_at = NOW()`); }
+ if (contacted === false) { sets.push(`contacted_at = NULL`); }
+ if (typeof notes === 'string') { sets.push(`notes = $${i++}`); params.push(notes); }
+ if (sets.length === 0) return res.status(400).json({ error: 'no fields to update' });
+ sets.push(`updated_at = NOW()`);
+ const r = await query(
+ `UPDATE organizations SET ${sets.join(', ')} WHERE id = $1
+ RETURNING id, starred, contacted_at, notes`,
+ params);
+ if (!r.rowCount) return res.status(404).json({ error: 'not found' });
+ res.json(r.rows[0]);
+ } catch (e) { next(e); }
+});
+
+app.get('/categories', (_req, res) => {
+ res.json(CATEGORY_TYPES);
+});
+
+// Pipeline summary — one row per small-biz category with funnel counts.
+app.get('/pipeline', async (_req, res, next) => {
+ try {
+ const r = await query(`
+ SELECT
+ CASE
+ WHEN type IN ('salon','barbershop','nail_salon','beauty_supply','spa') THEN 'cosmetology'
+ WHEN type = 'chiropractor_office' THEN 'chiropractic'
+ WHEN type IN ('acupuncture_clinic','tcm_clinic','tcm_herbalist') THEN 'chinese_medicine'
+ WHEN type = 'dental_office' THEN 'dental'
+ WHEN type IN ('optometrist_office','optical_shop') THEN 'optometry'
+ WHEN type IN ('yoga_studio','pilates_studio','gym') THEN 'fitness'
+ WHEN type IN ('tattoo_studio','piercing_studio') THEN 'tattoo'
+ WHEN type IN ('vet_clinic','pet_grooming','pet_shop') THEN 'pets'
+ ELSE 'other'
+ END AS category,
+ COUNT(*) AS total,
+ COUNT(*) FILTER (WHERE NOT is_chain) AS independents,
+ COUNT(*) FILTER (WHERE NOT is_chain AND (website IS NULL OR website='')) AS prospects,
+ COUNT(*) FILTER (WHERE NOT is_chain AND (website IS NULL OR website='') AND domain_available = TRUE) AS domain_available,
+ COUNT(*) FILTER (WHERE NOT is_chain AND lead_score >= 80) AS warmest,
+ COUNT(*) FILTER (WHERE starred) AS starred,
+ COUNT(*) FILTER (WHERE contacted_at IS NOT NULL) AS contacted
+ FROM organizations
+ WHERE type IN (
+ 'salon','barbershop','nail_salon','beauty_supply','spa',
+ 'chiropractor_office',
+ 'acupuncture_clinic','tcm_clinic','tcm_herbalist',
+ 'dental_office','optometrist_office','optical_shop',
+ 'yoga_studio','pilates_studio','gym',
+ 'tattoo_studio','piercing_studio',
+ 'vet_clinic','pet_grooming','pet_shop'
+ )
+ GROUP BY category
+ ORDER BY warmest DESC, total DESC
+ `, []);
+ res.json({ count: r.rowCount, rows: r.rows });
+ } catch (e) { next(e); }
+});
+
+// Prospect counts per ZIP for the active filter set — used by the heat panel.
+app.get('/stats/by-zip', async (req, res, next) => {
+ try {
+ const { where, params } = buildOrgWhere(req.query);
+ where.push(`zip IS NOT NULL`);
+ const sql = `
+ SELECT zip,
+ COUNT(*) AS total,
+ COUNT(*) FILTER (WHERE website IS NULL OR website = '') AS prospects,
+ COUNT(*) FILTER (WHERE (website IS NULL OR website = '') AND phone IS NOT NULL AND phone <> '') AS prospects_with_phone
+ FROM organizations
+ WHERE ${where.join(' AND ')}
+ GROUP BY zip
+ ORDER BY prospects DESC NULLS LAST
+ LIMIT 50
+ `;
+ const r = await query(sql, params);
+ res.json({ count: r.rowCount, rows: r.rows });
+ } catch (e) { next(e); }
+});
+
+// Cities present in the result-set for the city filter dropdown.
+app.get('/stats/cities', async (req, res, next) => {
+ try {
+ const { where, params } = buildOrgWhere(req.query);
+ where.push(`city IS NOT NULL`);
+ const sql = `
+ SELECT city, COUNT(*) AS n FROM organizations
+ WHERE ${where.join(' AND ')} GROUP BY city ORDER BY n DESC LIMIT 100
+ `;
+ const r = await query(sql, params);
+ res.json({ count: r.rowCount, rows: r.rows });
+ } catch (e) { next(e); }
+});
+
+// Cold-outreach CSV — small-biz prospects ready for paste-into-CRM.
+// SECURITY: bulk PII export — admin only.
+app.get('/outreach.csv', requireRole('admin'), async (req, res, next) => {
+ try {
+ const limit = Math.min(Number(req.query.limit) || 5000, 20000);
+ const { where, params } = buildOrgWhere(req.query);
+ const sql = `
+ SELECT id, name, type, address, city, zip, phone, website,
+ lat, lng, suggested_domain, domain_available,
+ CASE WHEN website IS NOT NULL AND website <> '' THEN 'has_site' ELSE 'needs_site' END AS prospect_state,
+ 'https://www.google.com/maps/search/?api=1&query=' || lat || ',' || lng AS map_link,
+ 'https://www.godaddy.com/domainsearch/find?domainToCheck=' || COALESCE(suggested_domain, '') AS register_link
+ FROM organizations
+ WHERE ${where.join(' AND ')}
+ ${orderClause(req.query.sort || 'prospects')}
+ LIMIT ${limit}
+ `;
+ const r = await query(sql, params);
+ const cols = ['id','name','type','prospect_state','suggested_domain','domain_available',
+ 'phone','address','city','zip','website',
+ 'lat','lng','map_link','register_link'];
+ res.setHeader('Content-Type','text/csv; charset=utf-8');
+ // Restrict filename slug to safe ASCII to prevent CRLF injection in headers.
+ const safeSlug = String(req.query.category || 'all').toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 32) || 'all';
+ res.setHeader('Content-Disposition',`attachment; filename="outreach-${safeSlug}.csv"`);
+ const stringifier = stringify({ header: true, columns: cols });
+ stringifier.pipe(res);
+ for (const row of r.rows) stringifier.write(row);
+ stringifier.end();
+ } catch (e) { next(e); }
+});
+
app.get('/organizations/:id', async (req, res, next) => {
try {
const r = await query(
@@ -137,6 +447,93 @@ app.get('/organizations/:id', async (req, res, next) => {
} catch (e) { next(e); }
});
+// ─── /organizations/:id/emails — recipient list for pitch modal ───────────
+app.get('/organizations/:id/emails', async (req, res, next) => {
+ try {
+ const r = await query(
+ `SELECT email, email_type, source_url, last_verified_at, verification_status
+ FROM emails WHERE organization_id = $1
+ ORDER BY last_verified_at DESC NULLS LAST, id`,
+ [req.params.id]
+ );
+ res.json({ count: r.rowCount, rows: r.rows });
+ } catch (e) { next(e); }
+});
+
+// ─── /preview/:orgId — "the live revamp the prospect can visit" ────────────
+// A standalone, beautifully-styled landing page populated from the org's real
+// data. Used as the destination URL in pitch emails ("don't chat — go see it").
+app.get('/preview/:orgId', async (req, res, next) => {
+ try {
+ const o = (await query(
+ `SELECT * FROM organizations WHERE id = $1 ${OPT_OUT}`,
+ [req.params.orgId]
+ )).rows[0];
+ if (!o) return res.status(404).send('Not found');
+
+ const pros = (await query(
+ `SELECT p.id, p.full_name, p.title, p.primary_specialty, p.npi_number
+ FROM professional_locations pl
+ JOIN professionals p ON p.id = pl.professional_id
+ WHERE pl.organization_id = $1 AND p.opted_out = false
+ ORDER BY p.source_confidence_score DESC NULLS LAST, p.full_name
+ LIMIT 12`,
+ [o.id]
+ )).rows;
+
+ res.setHeader('Content-Type','text/html; charset=utf-8');
+ res.end(renderPreview(o, pros));
+ } catch (e) { next(e); }
+});
+
+// 3-up index showing all variants for the same org (a/b/c side by side).
+// Use this in the pitch flow so the prospect can pick the direction they like.
+app.get('/preview/:orgId/all', async (req, res, next) => {
+ try {
+ const o = (await query(
+ `SELECT id, name FROM organizations WHERE id = $1 ${OPT_OUT}`,
+ [req.params.orgId]
+ )).rows[0];
+ if (!o) return res.status(404).send('Not found');
+ res.setHeader('Content-Type','text/html; charset=utf-8');
+ res.end(renderPreviewVariantIndex(o));
+ } catch (e) { next(e); }
+});
+
+// 3 distinct mockup directions per org. Hash-keyed by orgId so the same org
+// always gets the same 3 designs across reloads (deterministic per-org variation
+// in palette + accent + hero shape inside each template). Variant validation
+// happens in the handler — Express 5's path-to-regexp v8 dropped inline regex
+// like ":variant(a|b|c)", so we accept any token and 404 in the handler.
+app.get('/preview/:orgId/:variant', async (req, res, next) => {
+ try {
+ const v = req.params.variant;
+ const VALID = new Set(['a','b','c','d','e','modern','community','premium','wellness','editorial','brutalist']);
+ if (!VALID.has(v)) return res.status(404).send('Not found');
+ const o = (await query(
+ `SELECT * FROM organizations WHERE id = $1 ${OPT_OUT}`,
+ [req.params.orgId]
+ )).rows[0];
+ if (!o) return res.status(404).send('Not found');
+ const pros = (await query(
+ `SELECT p.id, p.full_name, p.title, p.primary_specialty, p.npi_number
+ FROM professional_locations pl
+ JOIN professionals p ON p.id = pl.professional_id
+ WHERE pl.organization_id = $1 AND p.opted_out = false
+ ORDER BY p.source_confidence_score DESC NULLS LAST, p.full_name
+ LIMIT 12`,
+ [o.id]
+ )).rows;
+ const renderer = (v === 'a' || v === 'modern') ? renderPreviewModernClinical
+ : (v === 'b' || v === 'community') ? renderPreviewWarmCommunity
+ : (v === 'c' || v === 'premium') ? renderPreviewPremiumWellness
+ : (v === 'd' || v === 'wellness') ? renderPreviewWellnessApp
+ : renderPreviewEditorialBrutalist;
+ res.setHeader('Content-Type','text/html; charset=utf-8');
+ res.end(renderer(o, pros));
+ } catch (e) { next(e); }
+});
+
// ─── Search ────────────────────────────────────────────────────────────────
app.get('/search', async (req, res, next) => {
try {
@@ -183,7 +580,8 @@ app.get('/website-data/:npi', async (req, res, next) => {
});
// ─── CSV export ─────────────────────────────────────────────────────────────
-app.get('/export.csv', async (req, res, next) => {
+// SECURITY: bulk PII (NPI, license, school) — admin only.
+app.get('/export.csv', requireRole('admin'), async (req, res, next) => {
try {
const entity = (req.query.entity || 'professionals').toString();
let sql, cols;
@@ -197,7 +595,9 @@ app.get('/export.csv', async (req, res, next) => {
}
const r = await query(sql, []);
res.setHeader('Content-Type','text/csv; charset=utf-8');
- res.setHeader('Content-Disposition',`attachment; filename="${entity}.csv"`);
+ // entity is already validated to one of the known set above, but defense-in-depth:
+ const safeEntity = String(entity).replace(/[^a-z0-9_-]/gi, '').slice(0, 32) || 'export';
+ res.setHeader('Content-Disposition',`attachment; filename="${safeEntity}.csv"`);
const stringifier = stringify({ header: true, columns: cols });
stringifier.pipe(res);
for (const row of r.rows) stringifier.write(row);
@@ -206,7 +606,10 @@ app.get('/export.csv', async (req, res, next) => {
});
// ─── Opt-out ────────────────────────────────────────────────────────────────
-app.post('/opt-out', async (req, res, next) => {
+// SECURITY: was unauthenticated — anyone could flip opted_out=TRUE on any
+// professional or org, mass-removing the directory. Now admin-only until a
+// signed-token flow (emailed to the practitioner) is built. See REVIEW-2026-05-04.md.
+app.post('/opt-out', requireRole('admin'), async (req, res, next) => {
try {
const { type, id } = req.body || {};
if (!['professional','organization'].includes(type)) {
@@ -225,9 +628,15 @@ app.post('/opt-out', async (req, res, next) => {
});
// ─── Errors ─────────────────────────────────────────────────────────────────
+// SECURITY: PG error messages can leak table/column/constraint names + query
+// fragments. Log full error server-side, return generic message to client.
app.use((err, _req, res, _next) => {
console.error('[api]', err);
- res.status(500).json({ error: err.message });
+ const isProd = process.env.NODE_ENV === 'production';
+ res.status(500).json({
+ error: 'internal_server_error',
+ ...(isProd ? {} : { detail: err.message }),
+ });
});
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -245,44 +654,1684 @@ async function getCounts() {
return r.rows[0];
}
+// ─── Preview-page renderer ─────────────────────────────────────────────────
+// Generates a clean, modern public-style landing page for an organization.
+// Adapts hero copy + service language by org.type so a senior-care RCFE
+// reads differently from an MD's medical group, but the layout is constant.
+const PREVIEW_COPY = {
+ rcfe: { kind: 'Senior Living', tagline: 'A warm, dignified home for the next chapter.', cta: 'Schedule a Tour', services: ['Assisted Living','Memory Care','Wellness Programs','Daily Activities','24-Hour Caregiving','Chef-Prepared Dining'] },
+ ccrc: { kind: 'Continuing Care Community', tagline: 'Independence today. Care for whatever tomorrow brings.', cta: 'Book a Visit', services: ['Independent Living','Assisted Living','Memory Care','Skilled Nursing','Wellness Programs','Resident Activities'] },
+ adult_residential: { kind: 'Residential Care', tagline: 'A supportive home where every resident is known.', cta: 'Schedule a Tour', services: ['Personal Care','Medication Management','Meals & Nutrition','Daily Activities','Transportation','Specialized Support'] },
+ adult_day_program: { kind: 'Adult Day Program', tagline: 'Engaged days. Peace of mind for families.', cta: 'Schedule a Visit', services: ['Therapeutic Activities','Group Outings','Meals & Snacks','Caregiver Respite','Transportation','Health Monitoring'] },
+ home_care_agency: { kind: 'Home Care', tagline: 'The care your family needs, in the home you love.', cta: 'Request a Free Consultation', services: ['Personal Care','Companionship','Meal Preparation','Medication Reminders','Transportation','Hospital-to-Home Transitions'] },
+ home_health: { kind: 'Home Health', tagline: 'Skilled clinical care delivered at home.', cta: 'Request a Visit', services: ['Skilled Nursing','Physical Therapy','Occupational Therapy','Speech Therapy','Wound Care','Medication Management'] },
+ hospice: { kind: 'Hospice & Palliative', tagline: 'Comfort, dignity, and presence — wherever home is.', cta: 'Speak with a Counselor', services: ['Pain & Symptom Management','Nursing Visits','Spiritual & Emotional Support','Family Counseling','Bereavement Care','24-Hour On-Call'] },
+ nursing_facility: { kind: 'Skilled Nursing', tagline: 'Round-the-clock skilled care, in a place that feels like home.', cta: 'Schedule a Visit', services: ['Skilled Nursing','Rehabilitation','Long-Term Care','Memory Care','IV Therapy','Wound Care'] },
+ medical_group: { kind: 'Medical Practice', tagline: 'Care that knows you, schedules that respect you.', cta: 'Book an Appointment', services: ['Same-Week Appointments','Online Scheduling','Lab & Imaging On-Site','Telehealth Visits','Insurance Billing','Bilingual Staff'] },
+ clinic: { kind: 'Clinic', tagline: 'Walk-in care without the wait.', cta: 'Book an Appointment', services: ['Walk-In Visits','Same-Week Scheduling','Lab Work','Imaging','Telehealth','Insurance Billing'] },
+ dental_office: { kind: 'Dental', tagline: 'Modern dentistry. Honest pricing. Quiet rooms.', cta: 'Book a Cleaning', services: ['Cleanings & Exams','Cosmetic Dentistry','Implants','Invisalign','Emergency Visits','Children Welcome'] },
+ optometrist_office: { kind: 'Optometry', tagline: 'Better vision, better days.', cta: 'Book an Eye Exam', services: ['Eye Exams','Glasses & Frames','Contact Lens Fittings','Pediatric Eye Care','Dry Eye Treatment','LASIK Consultation'] },
+ surgery_center: { kind: 'Surgery Center', tagline: 'Outpatient care. Hospital expertise.', cta: 'Request a Consult', services: ['Outpatient Surgery','Anesthesia Services','Pre-Op Evaluations','Same-Day Discharge','Insurance Billing','Specialized Recovery'] },
+ hospital: { kind: 'Hospital', tagline: 'Care for every moment of your life.', cta: 'Find a Doctor', services: ['Emergency Department','Surgical Services','Maternity','Imaging','Cardiology','Specialty Clinics'] },
+ fqhc: { kind: 'Community Health Center', tagline: 'Quality care for everyone in our community.', cta: 'Become a Patient', services: ['Primary Care','Pediatrics','Dental','Behavioral Health','Sliding-Scale Fees','Insurance Enrollment Help'] },
+ tcm_clinic: { kind: 'Acupuncture & TCM', tagline: 'Time-honored care for modern lives.', cta: 'Book a Session', services: ['Acupuncture','Cupping','Herbal Formulas','Tui Na Massage','Nutrition Counseling','Stress & Pain Relief'] },
+ acupuncture_clinic: { kind: 'Acupuncture', tagline: 'Time-honored care for modern lives.', cta: 'Book a Session', services: ['Acupuncture','Pain Relief','Stress Reduction','Fertility Support','Wellness Plans','Insurance-Friendly'] },
+ chiropractor_office: { kind: 'Chiropractic', tagline: 'Move better. Live better. Feel better.', cta: 'Book an Adjustment', services: ['Spinal Adjustments','Sports Injury Care','Posture Therapy','Massage','Pre-Natal Care','Insurance-Friendly'] },
+};
+const PREVIEW_DEFAULT = { kind: 'Local Practice', tagline: 'Care that knows you. Service that respects your time.', cta: 'Get in Touch', services: ['Welcoming Front Desk','Online Booking','Insurance-Friendly','Bilingual Staff','Same-Week Appointments','Patient Portal'] };
+
+function escHtml(s) {
+ return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+}
+// Allowlist for href values — strips javascript:/data:/vbscript: and only emits http(s).
+function safeUrl(u) {
+ const s = String(u || '').trim();
+ if (!s) return '';
+ if (!/^https?:\/\//i.test(s)) return '';
+ return s;
+}
+function titleCase(s) {
+ return String(s || '').toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
+}
+function fmtPhone(p) {
+ if (!p) return '';
+ const d = String(p).replace(/\D/g,'');
+ if (d.length === 10) return `(${d.slice(0,3)}) ${d.slice(3,6)}-${d.slice(6)}`;
+ if (d.length === 11 && d[0] === '1') return `(${d.slice(1,4)}) ${d.slice(4,7)}-${d.slice(7)}`;
+ return p;
+}
+
+function renderPreview(o, pros) {
+ const copy = PREVIEW_COPY[o.type] || PREVIEW_DEFAULT;
+ const displayName = titleCase(o.name || 'Our Practice');
+ const cityZip = [titleCase(o.city || ''), o.zip || ''].filter(Boolean).join(', ');
+ const phone = fmtPhone(o.phone);
+ const phoneTel = (o.phone || '').replace(/[^+0-9]/g,'');
+ // SECURITY: lat/lng come from PG as strings; address is feed-text.
+ // Encode every component so a quote in addr can't break out of href="...".
+ const mapHref = (o.lat && o.lng)
+ ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(o.lat)},${encodeURIComponent(o.lng)}`
+ : (o.address
+ ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent([o.address,o.city,o.state,o.zip].filter(Boolean).join(', '))}`
+ : '#');
+ const isMedicalCohort = pros && pros.length > 0;
+
+ const team = isMedicalCohort ? pros.slice(0, 8).map(p => {
+ const initials = (p.full_name || '').split(/\s+/).filter(Boolean).slice(0,2).map(s => s[0]).join('').toUpperCase() || '·';
+ return `<div class="team-card">
+ <div class="avatar">${escHtml(initials)}</div>
+ <div class="team-name">${escHtml(titleCase(p.full_name))}${p.title ? ', ' + escHtml(p.title) : ''}</div>
+ <div class="team-spec">${escHtml(p.primary_specialty || '')}</div>
+ </div>`;
+ }).join('') : '';
+
+ const services = copy.services.map(s => `<li>${escHtml(s)}</li>`).join('');
+
+ return `<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width,initial-scale=1">
+ <title>${escHtml(displayName)} — ${escHtml(copy.kind)}</title>
+ <meta name="description" content="${escHtml(displayName)} — ${escHtml(copy.tagline)}">
+ <style>
+ *,*::before,*::after { box-sizing:border-box; }
+ :root {
+ --cream: #fbf7f0;
+ --ink: #1c1f1c;
+ --ink-soft: #4a504a;
+ --line: #e6dfd1;
+ --moss: #3f5f4a;
+ --moss-deep: #28412f;
+ --gold: #b48a3a;
+ --rose: #c97e74;
+ }
+ html, body { margin:0; }
+ body {
+ font: 16px/1.6 ui-serif, "Iowan Old Style", "Apple Garamond", Garamond, "Times New Roman", serif;
+ color: var(--ink); background: var(--cream); -webkit-font-smoothing: antialiased;
+ }
+ .nav { padding: 22px 32px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); background: rgba(251,247,240,0.92); backdrop-filter: blur(8px); position:sticky; top:0; z-index:10; }
+ .nav .brand { font-weight:600; font-size:18px; letter-spacing:-0.01em; }
+ .nav .brand small { font-weight:400; color:var(--ink-soft); margin-left:10px; font-size:13px; letter-spacing:0.04em; text-transform:uppercase; }
+ .nav .links a { color:var(--ink); text-decoration:none; margin-left:26px; font: 14px/1.4 ui-sans-serif, system-ui, sans-serif; letter-spacing:0.02em; }
+ .nav .cta { background:var(--moss); color:#fff; padding:9px 16px; border-radius:999px; font: 600 13px/1 ui-sans-serif,system-ui,sans-serif; letter-spacing:0.05em; text-transform:uppercase; text-decoration:none; }
+
+ .hero { padding: 80px 32px 64px; max-width: 1120px; margin: 0 auto; display:grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items:center; }
+ .hero h1 { font-size: clamp(36px, 5vw, 60px); line-height:1.05; letter-spacing:-0.015em; margin: 0 0 22px; font-weight: 500; }
+ .hero p.tagline { font-size: 19px; line-height:1.55; color: var(--ink-soft); margin: 0 0 32px; max-width: 520px; }
+ .hero .actions { display:flex; gap: 14px; flex-wrap:wrap; align-items:center; }
+ .btn-primary { background: var(--moss); color:#fff; padding: 14px 26px; border-radius: 999px; font: 600 14px/1 ui-sans-serif,system-ui,sans-serif; letter-spacing:0.05em; text-transform:uppercase; text-decoration:none; display:inline-block; }
+ .btn-primary:hover { background: var(--moss-deep); }
+ .btn-secondary { color: var(--ink); padding: 14px 22px; font: 600 14px/1 ui-sans-serif,system-ui,sans-serif; letter-spacing:0.05em; text-transform:uppercase; text-decoration:none; border-bottom:1px solid var(--ink); }
+ .distance-badge { display:inline-flex; gap:8px; align-items:center; margin: 0 0 22px; padding: 8px 14px; background: rgba(63,95,74,0.08); border:1px solid var(--moss); color: var(--moss-deep); border-radius: 999px; font: 600 13px/1.2 ui-sans-serif, system-ui, sans-serif; letter-spacing:0.02em; }
+ .distance-badge::before { content: '📍'; font-size: 14px; }
+ .hero .photo { aspect-ratio: 5/4; border-radius: 6px; background: linear-gradient(135deg, #d4cdb8 0%, #b9a988 50%, #8a8771 100%); position:relative; overflow:hidden; box-shadow: 0 30px 60px -30px rgba(0,0,0,0.25); }
+ .hero .photo::after {
+ content: ""; position:absolute; inset:0;
+ background:
+ radial-gradient(circle at 30% 30%, rgba(255,247,225,0.5), transparent 50%),
+ radial-gradient(circle at 70% 70%, rgba(63,95,74,0.25), transparent 60%);
+ }
+ .hero .photo .photo-label { position:absolute; left:24px; bottom:22px; right:24px; color:#fbf7f0; font: 500 14px/1.45 ui-sans-serif,system-ui,sans-serif; letter-spacing:0.03em; z-index:1; }
+
+ .stat-strip { padding: 28px 32px; background:var(--moss); color:#fbf7f0; }
+ .stat-strip .row { max-width:1120px; margin:0 auto; display:grid; grid-template-columns: repeat(4, 1fr); gap:32px; }
+ .stat-strip .stat { font: 14px/1.5 ui-sans-serif,system-ui,sans-serif; letter-spacing:0.02em; }
+ .stat-strip .stat b { display:block; font: 500 28px/1 ui-serif, serif; margin-bottom:6px; color:#fff; letter-spacing:-0.01em; }
+
+ section { max-width: 1120px; margin: 0 auto; padding: 88px 32px; }
+ .eyebrow { color: var(--gold); font: 600 12px/1 ui-sans-serif,system-ui,sans-serif; letter-spacing:0.18em; text-transform:uppercase; margin-bottom: 18px; }
+ h2 { font-size: clamp(28px, 3.5vw, 40px); margin: 0 0 32px; font-weight: 500; letter-spacing:-0.01em; line-height:1.1; }
+ h3 { font-size: 19px; margin: 0 0 8px; font-weight: 600; letter-spacing:-0.005em; }
+
+ .services { display:grid; grid-template-columns: repeat(3, 1fr); gap: 18px; list-style:none; padding:0; margin:0; }
+ .services li { padding: 26px 22px; border:1px solid var(--line); border-radius: 8px; background: #fff; font: 500 16px/1.4 ui-sans-serif,system-ui,sans-serif; letter-spacing:-0.005em; }
+
+ .team { display:grid; grid-template-columns: repeat(4, 1fr); gap: 22px; }
+ .team-card { padding: 22px; border:1px solid var(--line); border-radius: 8px; background:#fff; text-align:left; }
+ .team-card .avatar { width:54px; height:54px; border-radius:50%; background:var(--moss); color:#fbf7f0; display:flex; align-items:center; justify-content:center; font: 600 16px/1 ui-sans-serif,system-ui,sans-serif; letter-spacing:0.04em; margin-bottom:14px; }
+ .team-card .team-name { font: 600 14px/1.35 ui-sans-serif,system-ui,sans-serif; margin-bottom:4px; letter-spacing:-0.005em; }
+ .team-card .team-spec { font: 13px/1.4 ui-sans-serif,system-ui,sans-serif; color:var(--ink-soft); }
+
+ .visit { display:grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items:start; padding-bottom: 60px; }
+ .visit-card { padding:30px; border:1px solid var(--line); border-radius: 10px; background:#fff; }
+ .visit-card .row { display:flex; gap:14px; padding: 16px 0; border-bottom: 1px solid var(--line); align-items:flex-start; }
+ .visit-card .row:last-child { border-bottom:0; }
+ .visit-card .label { width: 80px; flex:none; color:var(--ink-soft); font: 12px/1.4 ui-sans-serif,system-ui,sans-serif; letter-spacing:0.08em; text-transform:uppercase; padding-top:2px; }
+ .visit-card .value { flex:1; font: 16px/1.55 ui-sans-serif,system-ui,sans-serif; color:var(--ink); letter-spacing:-0.005em; }
+ .visit-card .value a { color: var(--moss-deep); text-decoration:none; border-bottom:1px solid var(--line); padding-bottom:1px; }
+
+ .map-frame { aspect-ratio: 4/3; border-radius:10px; background:#dad3c5 url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'><path d='M0 50 Q25 30 50 50 T100 50' fill='none' stroke='%23a89e88' stroke-width='1.5'/><path d='M0 70 Q25 50 50 70 T100 70' fill='none' stroke='%23a89e88' stroke-width='1'/><circle cx='50' cy='50' r='8' fill='%23${(o.type === 'rcfe' || o.type === 'ccrc' ? 'b48a3a' : '3f5f4a')}'/></svg>") center/cover no-repeat; position:relative; overflow:hidden; }
+ .map-frame a { position:absolute; inset:auto 16px 16px auto; background:#fff; padding: 9px 14px; border-radius:999px; font: 600 12px/1 ui-sans-serif,system-ui,sans-serif; color: var(--ink); text-decoration:none; letter-spacing:0.06em; text-transform:uppercase; box-shadow: 0 4px 18px rgba(0,0,0,0.12); }
+
+ footer { padding: 56px 32px; background: var(--moss-deep); color: rgba(251,247,240,0.85); }
+ footer .inner { max-width: 1120px; margin: 0 auto; display:flex; align-items:flex-end; justify-content:space-between; flex-wrap:wrap; gap: 30px; font: 14px/1.6 ui-sans-serif,system-ui,sans-serif; letter-spacing:0.02em; }
+ footer .inner h4 { font: 500 22px/1.1 ui-serif, serif; color:#fff; margin: 0 0 8px; letter-spacing:-0.01em; }
+ footer .draft { padding:6px 12px; background: var(--gold); color: var(--moss-deep); border-radius: 999px; font: 700 11px/1 ui-sans-serif,system-ui,sans-serif; letter-spacing:0.12em; text-transform:uppercase; }
+
+ @media (max-width: 880px) {
+ .hero { grid-template-columns: 1fr; padding: 56px 24px 40px; gap: 36px; }
+ .hero .photo { order:-1; aspect-ratio: 4/3; }
+ .stat-strip .row { grid-template-columns: repeat(2, 1fr); gap: 20px; }
+ .services { grid-template-columns: 1fr; }
+ .team { grid-template-columns: repeat(2, 1fr); }
+ .visit { grid-template-columns: 1fr; gap: 32px; }
+ section { padding: 56px 24px; }
+ .nav { padding: 16px 22px; }
+ .nav .links { display:none; }
+ }
+ </style>
+</head>
+<body>
+ <nav class="nav">
+ <div class="brand">${escHtml(displayName)}<small>${escHtml(copy.kind)}</small></div>
+ <div class="links">
+ <a href="#services">Services</a>
+ ${isMedicalCohort ? '<a href="#team">Our Team</a>' : ''}
+ <a href="#visit">Visit</a>
+ </div>
+ ${phone ? `<a class="cta" href="tel:${phoneTel}">${escHtml(phone)}</a>` : `<a class="cta" href="#visit">Get in Touch</a>`}
+ </nav>
+
+ <section class="hero">
+ <div>
+ <h1>${escHtml(copy.tagline)}</h1>
+ <p class="tagline">${escHtml(displayName)} ${cityZip ? 'in ' + escHtml(cityZip) + '. ' : ''}Built around the people we serve, not the paperwork.</p>
+ <div id="distance-badge" class="distance-badge" style="display:none"></div>
+ <div class="actions">
+ ${phone ? `<a class="btn-primary" href="tel:${phoneTel}">${escHtml(copy.cta)}</a>` : `<a class="btn-primary" href="#visit">${escHtml(copy.cta)}</a>`}
+ <a class="btn-secondary" href="#services">Learn More →</a>
+ <a class="btn-secondary" id="directions-link" href="${escHtml(mapHref)}" target="_blank" rel="noopener">Directions →</a>
+ </div>
+ </div>
+ <div class="photo"><div class="photo-label">${escHtml(copy.kind)} · ${escHtml(titleCase(o.city || 'Los Angeles County'))}</div></div>
+ </section>
+
+ <div class="stat-strip">
+ <div class="row">
+ <div class="stat"><b>${cityZip ? '90s' : '—'}</b>Years of trust in our community</div>
+ <div class="stat"><b>${pros.length || '12+'}</b>${pros.length ? 'On our team' : 'Specialists in network'}</div>
+ <div class="stat"><b>5★</b>Average patient rating</div>
+ <div class="stat"><b>24/7</b>Help when you need it</div>
+ </div>
+ </div>
+
+ <section id="services">
+ <div class="eyebrow">What we offer</div>
+ <h2>Services designed around real lives.</h2>
+ <ul class="services">${services}</ul>
+ </section>
+
+ ${isMedicalCohort ? `
+ <section id="team" style="background:#f5efe2;border-top:1px solid var(--line);border-bottom:1px solid var(--line);max-width:none;padding:88px 32px">
+ <div style="max-width:1120px;margin:0 auto">
+ <div class="eyebrow">People</div>
+ <h2>Meet our team.</h2>
+ <div class="team">${team}</div>
+ </div>
+ </section>` : ''}
+
+ <section id="visit" class="visit">
+ <div>
+ <div class="eyebrow">Plan your visit</div>
+ <h2>Stop by, or call ahead.</h2>
+ <div class="visit-card">
+ ${o.address ? `<div class="row"><div class="label">Address</div><div class="value"><a href="${escHtml(mapHref)}" target="_blank" rel="noopener">${escHtml(titleCase(o.address))}<br>${escHtml(cityZip)}</a></div></div>` : ''}
+ ${phone ? `<div class="row"><div class="label">Phone</div><div class="value"><a href="tel:${phoneTel}">${escHtml(phone)}</a></div></div>` : ''}
+ ${(() => { const u = safeUrl(o.website); return u ? `<div class="row"><div class="label">Web</div><div class="value"><a href="${escHtml(u)}" target="_blank" rel="noopener">${escHtml(u.replace(new RegExp('^https?://'),'').replace(new RegExp('/$'),''))}</a></div></div>` : ''; })()}
+ <div class="row"><div class="label">Hours</div><div class="value">Mon – Fri · 9 a.m. – 5 p.m.<br>Sat by appointment</div></div>
+ </div>
+ </div>
+ <a class="map-frame" href="${escHtml(mapHref)}" target="_blank" rel="noopener" aria-label="Open in Google Maps">
+ <span>Open in Maps →</span>
+ </a>
+ </section>
+
+ <footer>
+ <div class="inner">
+ <div>
+ <h4>${escHtml(displayName)}</h4>
+ ${o.address ? escHtml(titleCase(o.address)) + '<br>' : ''}
+ ${cityZip ? escHtml(cityZip) : ''}${phone ? ' · ' + escHtml(phone) : ''}
+ </div>
+ <span class="draft">Concept Preview</span>
+ </div>
+ </footer>
+
+ <script>
+ // Geolocation: on load, request the visitor's current position; if granted,
+ // show "X miles away" in the hero and rewrite the Directions link to start
+ // from their location instead of just the destination.
+ (function() {
+ var ORG_LAT = ${o.lat ? Number(o.lat) : 'null'};
+ var ORG_LNG = ${o.lng ? Number(o.lng) : 'null'};
+ if (ORG_LAT == null || ORG_LNG == null || !navigator.geolocation) return;
+
+ function haversineMi(lat1, lng1, lat2, lng2) {
+ var R = 3958.8; // miles
+ var toRad = function(d){ return d * Math.PI / 180; };
+ var dLat = toRad(lat2 - lat1), dLng = toRad(lng2 - lng1);
+ var a = Math.sin(dLat/2)*Math.sin(dLat/2) +
+ Math.cos(toRad(lat1))*Math.cos(toRad(lat2)) *
+ Math.sin(dLng/2)*Math.sin(dLng/2);
+ return 2 * R * Math.asin(Math.sqrt(a));
+ }
+
+ navigator.geolocation.getCurrentPosition(function(pos) {
+ var miles = haversineMi(pos.coords.latitude, pos.coords.longitude, ORG_LAT, ORG_LNG);
+ var fmt = miles < 1 ? '< 1 mile away' : miles.toFixed(1) + ' miles from you';
+ var badge = document.getElementById('distance-badge');
+ if (badge) { badge.textContent = fmt; badge.style.display = 'inline-flex'; }
+ var dir = document.getElementById('directions-link');
+ if (dir) {
+ dir.href = 'https://www.google.com/maps/dir/?api=1&origin=' +
+ pos.coords.latitude + ',' + pos.coords.longitude +
+ '&destination=' + ORG_LAT + ',' + ORG_LNG;
+ }
+ }, function(){ /* user denied — leave defaults */ }, { enableHighAccuracy: false, timeout: 7000, maximumAge: 600000 });
+ })();
+ </script>
+</body>
+</html>`;
+}
+
+// ─── Mockup variants — 3 distinct design directions per org ─────────────────
+// Each template is parameterized by a deterministic per-org seed so two orgs in
+// the same template never look identical (palette + hero shape + heading rotate).
+// Inspired by 21st.dev component patterns + Figma-style design tokens.
+//
+// pickSeed: stable 0..N-1 from string. Used to rotate palette + hero shape.
+function pickSeed(str, mod) {
+ let h = 0;
+ const s = String(str || '');
+ for (let i = 0; i < s.length; i++) h = ((h << 5) - h) + s.charCodeAt(i) | 0;
+ return Math.abs(h) % mod;
+}
+
+// splitNameAccent: returns { lead, tail } for templates that wrap the last word
+// in <em>. For single-word names, lead is empty and the caller should NOT emit
+// a leading span (otherwise template B/C produce an empty span + italic-everything).
+function splitNameAccent(name) {
+ const parts = String(name || '').split(/\s+/).filter(Boolean);
+ if (parts.length <= 1) return { lead: '', tail: parts[0] || '' };
+ return { lead: parts.slice(0, -1).join(' '), tail: parts[parts.length - 1] };
+}
+
+// ctaHref: prefer phone tel: link; fall back to in-page #contact anchor instead
+// of dead "#" so the CTA actually scrolls somewhere when no phone is on file.
+function ctaHref(phoneTel) {
+ return phoneTel ? `tel:${phoneTel}` : '#contact';
+}
+
+// ─── Template A: Modern Clinical ──────────────────────────────────────────────
+// Minimalist whitespace, sans-serif, generous line-height, accent strip.
+// Palette rotates across 5 cool clinical tones; hero shape rotates 3 ways.
+function renderPreviewModernClinical(o, pros) {
+ const copy = PREVIEW_COPY[o.type] || PREVIEW_DEFAULT;
+ const name = titleCase(o.name || 'Our Practice');
+ const cityZip = [titleCase(o.city || ''), o.zip || ''].filter(Boolean).join(', ');
+ const phone = fmtPhone(o.phone);
+ const phoneTel = (o.phone || '').replace(/[^+0-9]/g,'');
+ const palettes = [
+ { bg:'#F7F9FB', ink:'#0E1726', accent:'#0F766E', soft:'#E6F2F1', name:'teal' },
+ { bg:'#FAFAFC', ink:'#1F2937', accent:'#2563EB', soft:'#E8EFFE', name:'azure' },
+ { bg:'#F8FAFC', ink:'#0B1220', accent:'#0EA5E9', soft:'#E0F2FE', name:'sky' },
+ { bg:'#F4F8F4', ink:'#14241D', accent:'#15803D', soft:'#DCFCE7', name:'forest' },
+ { bg:'#FAF8F4', ink:'#221A0F', accent:'#B45309', soft:'#FDE9C9', name:'amber' },
+ ];
+ const p = palettes[pickSeed(o.id + 'A', palettes.length)];
+ const heroShape = pickSeed(o.id + 'A-hero', 3); // 0=split, 1=stacked, 2=banner
+ const services = copy.services.map(s =>
+ `<li><span class="dot"></span><span>${escHtml(s)}</span></li>`).join('');
+ const team = pros.slice(0, 4).map(p => {
+ const initials = (p.full_name||'').split(/\s+/).slice(0,2).map(s=>s[0]).join('').toUpperCase()||'·';
+ return `<div class="provider"><div class="ini">${escHtml(initials)}</div><div><div class="pn">${escHtml(titleCase(p.full_name))}${p.title?', '+escHtml(p.title):''}</div><div class="ps">${escHtml(p.primary_specialty||'')}</div></div></div>`;
+ }).join('');
+ const heroBlock = heroShape === 0 ? // split
+ `<div class="hero split"><div class="hero-text"><div class="kicker">${escHtml(copy.kind)} · ${escHtml(cityZip)}</div><h1>${escHtml(name)}</h1><p class="lede">${escHtml(copy.tagline)}</p><div class="cta-row"><a class="btn-primary" href="${ctaHref(phoneTel)}">${escHtml(copy.cta)}</a><a class="btn-ghost" href="#services">View services →</a></div></div><div class="hero-card"><div class="hc-line">Now accepting</div><div class="hc-big">New patients</div><div class="hc-sub">Same-week appointments</div></div></div>`
+ : heroShape === 1 ? // stacked centered
+ `<div class="hero stacked"><div class="kicker">${escHtml(copy.kind)} · ${escHtml(cityZip)}</div><h1>${escHtml(name)}</h1><p class="lede">${escHtml(copy.tagline)}</p><div class="cta-row centered"><a class="btn-primary" href="${ctaHref(phoneTel)}">${escHtml(copy.cta)}</a></div></div>`
+ : // banner with accent stripe
+ `<div class="hero banner"><div class="stripe"></div><div class="hb-inner"><div class="kicker">${escHtml(copy.kind)} · ${escHtml(cityZip)}</div><h1>${escHtml(name)}</h1><p class="lede">${escHtml(copy.tagline)}</p><a class="btn-primary" href="${ctaHref(phoneTel)}">${escHtml(copy.cta)}</a></div></div>`;
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escHtml(name)} — ${escHtml(copy.kind)}</title><style>
+*,*::before,*::after{box-sizing:border-box}html,body{margin:0;background:${p.bg};color:${p.ink};font:15px/1.6 ui-sans-serif,system-ui,-apple-system,'SF Pro Text','Inter',sans-serif;-webkit-font-smoothing:antialiased}
+.wrap{max-width:1180px;margin:0 auto;padding:0 28px}
+.nav{display:flex;align-items:center;justify-content:space-between;padding:22px 0;border-bottom:1px solid rgba(0,0,0,0.06)}
+.brand{font-weight:600;letter-spacing:-0.01em;font-size:17px}.brand small{color:#64748B;font-weight:400;margin-left:10px}
+.nav-links a{color:${p.ink};text-decoration:none;margin-left:28px;font-size:14px}
+.btn-primary{background:${p.accent};color:#fff;padding:13px 22px;border-radius:8px;font-weight:600;font-size:14px;text-decoration:none;display:inline-block;letter-spacing:0.01em}
+.btn-ghost{color:${p.ink};padding:13px 0;font-size:14px;text-decoration:none;border-bottom:1px solid ${p.ink};margin-left:18px}
+.kicker{color:${p.accent};font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:0.14em;margin-bottom:18px}
+.hero{padding:88px 0 72px}
+.hero h1{font-size:clamp(40px,5.5vw,68px);line-height:1.04;margin:0 0 22px;font-weight:600;letter-spacing:-0.025em}
+.hero .lede{font-size:19px;line-height:1.55;color:#475569;max-width:520px;margin:0 0 32px}
+.cta-row{display:flex;align-items:center;gap:0;flex-wrap:wrap}.cta-row.centered{justify-content:center}
+.hero.split{display:grid;grid-template-columns:1.2fr 1fr;gap:72px;align-items:center}
+.hero.stacked{text-align:center;max-width:760px;margin:0 auto}
+.hero.banner{position:relative;padding:120px 0 100px}.hero.banner .stripe{position:absolute;top:0;left:-28px;right:-28px;height:6px;background:linear-gradient(90deg,${p.accent},${p.accent}aa,${p.accent}66)}
+.hero-card{background:#fff;border:1px solid rgba(0,0,0,0.06);border-radius:14px;padding:36px;box-shadow:0 1px 0 rgba(0,0,0,0.02),0 30px 60px -40px rgba(0,0,0,0.18)}
+.hc-line{color:${p.accent};font-size:12px;font-weight:600;letter-spacing:0.14em;text-transform:uppercase;margin-bottom:8px}
+.hc-big{font-size:30px;font-weight:600;letter-spacing:-0.02em;margin-bottom:6px}.hc-sub{color:#64748B;font-size:15px}
+section{padding:80px 0;border-top:1px solid rgba(0,0,0,0.05)}
+section h2{font-size:clamp(26px,3.2vw,36px);font-weight:600;letter-spacing:-0.018em;margin:0 0 12px}
+section .sub{color:#64748B;margin:0 0 40px;font-size:16px}
+.services{list-style:none;padding:0;margin:0;display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
+.services li{display:flex;align-items:center;gap:14px;padding:22px 24px;background:#fff;border:1px solid rgba(0,0,0,0.06);border-radius:10px;font-weight:500;font-size:15.5px}
+.services li .dot{width:8px;height:8px;border-radius:50%;background:${p.accent};flex-shrink:0}
+.providers{display:grid;grid-template-columns:repeat(2,1fr);gap:14px}
+.provider{display:flex;align-items:center;gap:18px;padding:22px;background:#fff;border:1px solid rgba(0,0,0,0.06);border-radius:10px}
+.ini{width:48px;height:48px;border-radius:50%;background:${p.soft};color:${p.accent};font-weight:600;display:flex;align-items:center;justify-content:center;flex-shrink:0;letter-spacing:0.02em}
+.pn{font-weight:600;font-size:15px}.ps{color:#64748B;font-size:13px;margin-top:2px}
+.contact{background:${p.soft};padding:64px 0;text-align:center;border-radius:18px;margin:40px 0 80px}
+.contact h2{margin:0 0 12px;color:${p.ink}}.contact .lede{margin:0 0 28px;color:#475569}
+@media(max-width:780px){.hero.split{grid-template-columns:1fr}.services,.providers{grid-template-columns:1fr}}
+</style></head><body><div class="wrap">
+<nav class="nav"><div class="brand">${escHtml(name.split(/\s+/).slice(0,2).join(' '))}<small>${escHtml(copy.kind)}</small></div><div class="nav-links"><a href="#services">Services</a><a href="#team">Team</a><a href="#contact">Contact</a></div></nav>
+${heroBlock}
+<section id="services"><div class="kicker">What we do</div><h2>Care, with the friction taken out.</h2><p class="sub">Six things you can count on every visit.</p><ul class="services">${services}</ul></section>
+${pros.length?`<section id="team"><div class="kicker">Your providers</div><h2>The team you'll actually meet.</h2><p class="sub">${pros.length} licensed providers practicing here.</p><div class="providers">${team}</div></section>`:''}
+<section id="contact"><div class="contact"><div class="kicker">Get in touch</div><h2>${escHtml(copy.cta)}</h2><p class="lede">${cityZip?escHtml(cityZip):''}${phone?` · <a href="tel:${escHtml(phoneTel)}" style="color:${p.accent}">${escHtml(phone)}</a>`:''}</p>${phone?`<a class="btn-primary" href="tel:${escHtml(phoneTel)}">Call ${escHtml(phone)}</a>`:''}</div></section>
+</div><!-- variant=A modern-clinical · palette=${p.name} · hero=${heroShape} --></body></html>`;
+}
+
+// ─── Template B: Warm Community ───────────────────────────────────────────────
+// Earth tones, serif headlines, soft geometric badges. Family-practice feel.
+// Layout: editorial — tagline overlaps a colored card; service icons in circles.
+function renderPreviewWarmCommunity(o, pros) {
+ const copy = PREVIEW_COPY[o.type] || PREVIEW_DEFAULT;
+ const name = titleCase(o.name || 'Our Practice');
+ const cityZip = [titleCase(o.city || ''), o.zip || ''].filter(Boolean).join(', ');
+ const phone = fmtPhone(o.phone);
+ const phoneTel = (o.phone || '').replace(/[^+0-9]/g,'');
+ const palettes = [
+ { bg:'#FBF6EF', ink:'#2A1F12', accent:'#A16207', soft:'#F2E2C5', card:'#FFFAF1', name:'honey' },
+ { bg:'#F7F2EA', ink:'#1F1B16', accent:'#9F2F00', soft:'#F1D3C2', card:'#FFF6EE', name:'terracotta' },
+ { bg:'#F4F6F0', ink:'#1B2014', accent:'#4D7C0F', soft:'#DCEBC2', card:'#FBFCEF', name:'olive' },
+ { bg:'#FAF2F4', ink:'#241419', accent:'#9D174D', soft:'#F4D3DD', card:'#FFF7F9', name:'rose' },
+ { bg:'#F0EFF7', ink:'#191625', accent:'#5B21B6', soft:'#DCD6F5', card:'#F8F6FF', name:'lavender' },
+ ];
+ const p = palettes[pickSeed(o.id + 'B', palettes.length)];
+ const services = copy.services.map((s, i) =>
+ `<div class="svc"><div class="badge" style="transform:rotate(${(i%3-1)*4}deg)">${i+1}</div><div class="svc-text">${escHtml(s)}</div></div>`
+ ).join('');
+ const team = pros.slice(0, 6).map(p => {
+ const initials = (p.full_name||'').split(/\s+/).slice(0,2).map(s=>s[0]).join('').toUpperCase()||'·';
+ return `<div class="card"><div class="circle">${escHtml(initials)}</div><div><div class="cn">${escHtml(titleCase(p.full_name))}${p.title?', '+escHtml(p.title):''}</div><div class="cs">${escHtml(p.primary_specialty||'')}</div></div></div>`;
+ }).join('');
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escHtml(name)} — ${escHtml(copy.kind)}</title><style>
+*,*::before,*::after{box-sizing:border-box}html,body{margin:0;background:${p.bg};color:${p.ink};font:16px/1.65 ui-sans-serif,'Inter','Helvetica Neue',sans-serif;-webkit-font-smoothing:antialiased}
+.wrap{max-width:1100px;margin:0 auto;padding:0 28px}
+nav{display:flex;align-items:center;justify-content:space-between;padding:32px 0}
+.logo{font:600 18px/1 ui-serif,'Iowan Old Style','Apple Garamond',Garamond,serif;letter-spacing:-0.01em}
+.logo .dot{display:inline-block;width:10px;height:10px;background:${p.accent};border-radius:50%;margin-left:6px;vertical-align:middle}
+nav a{color:${p.ink};text-decoration:none;margin-left:24px;font-size:14px;opacity:0.75}
+.cta{background:${p.ink};color:${p.bg};padding:11px 18px;border-radius:999px;font-weight:600;font-size:13px;text-decoration:none}
+.hero{padding:64px 0 84px;display:grid;grid-template-columns:1.1fr 0.9fr;gap:48px;align-items:center}
+.hero h1{font:600 clamp(42px,6vw,72px)/1.02 ui-serif,'Iowan Old Style','Apple Garamond',Garamond,serif;letter-spacing:-0.02em;margin:0 0 24px}
+.hero h1 em{font-style:italic;color:${p.accent}}
+.hero .lede{font-size:19px;line-height:1.55;color:${p.ink};opacity:0.78;margin:0 0 32px;max-width:480px}
+.hero-card{background:${p.card};border:1px solid ${p.soft};border-radius:24px;padding:44px;position:relative;box-shadow:0 30px 80px -40px rgba(0,0,0,0.2)}
+.hero-card::before{content:'';position:absolute;top:-22px;right:-22px;width:80px;height:80px;background:${p.accent};border-radius:50%;opacity:0.85}
+.hc-eyebrow{font:600 11px/1 ui-sans-serif,sans-serif;letter-spacing:0.18em;text-transform:uppercase;color:${p.accent};margin-bottom:14px}
+.hc-h{font:600 26px/1.2 ui-serif,serif;margin-bottom:14px;letter-spacing:-0.01em}
+.hc-p{font-size:15px;line-height:1.55;opacity:0.78;margin:0}
+.hero-actions{display:flex;align-items:center;gap:18px}
+.btn-fill{background:${p.accent};color:#fff;padding:15px 26px;border-radius:999px;font-weight:600;text-decoration:none}
+.btn-link{color:${p.ink};text-decoration:underline;text-underline-offset:5px;font-weight:500}
+section{padding:80px 0}
+.eyebrow{font:600 11px/1 ui-sans-serif,sans-serif;letter-spacing:0.18em;text-transform:uppercase;color:${p.accent};margin-bottom:18px}
+h2{font:600 clamp(28px,3.4vw,40px)/1.1 ui-serif,'Iowan Old Style',serif;margin:0 0 14px;letter-spacing:-0.018em}
+.sub{font-size:17px;line-height:1.5;opacity:0.7;max-width:600px;margin:0 0 44px}
+.services-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px}
+.svc{display:flex;align-items:center;gap:18px;padding:24px;background:${p.card};border-radius:16px;border:1px solid ${p.soft}}
+.badge{flex-shrink:0;width:40px;height:40px;border-radius:10px;background:${p.accent};color:#fff;display:flex;align-items:center;justify-content:center;font:700 16px/1 ui-sans-serif,sans-serif}
+.svc-text{font-weight:500;font-size:15.5px}
+.team-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:20px}
+.card{display:flex;align-items:center;gap:18px;padding:24px;background:${p.card};border:1px solid ${p.soft};border-radius:16px}
+.circle{width:54px;height:54px;border-radius:50%;background:${p.soft};color:${p.accent};font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-family:ui-serif,serif;font-size:18px}
+.cn{font-weight:600;font-size:15px;font-family:ui-serif,serif}.cs{opacity:0.65;font-size:13px;margin-top:3px}
+.contact-strip{background:${p.ink};color:${p.bg};border-radius:24px;padding:64px 48px;text-align:center;margin:40px 0 80px}
+.contact-strip h2{color:${p.bg}}.contact-strip .sub{color:${p.bg};opacity:0.75;margin-bottom:32px}
+.contact-strip .btn-fill{background:${p.accent}}
+@media(max-width:780px){.hero{grid-template-columns:1fr}.services-grid,.team-grid{grid-template-columns:1fr}}
+</style></head><body><div class="wrap">
+<nav><div class="logo">${escHtml(name.split(/\s+/).slice(0,2).join(' '))}<span class="dot"></span></div><div><a href="#services">Services</a><a href="#team">Our Team</a><a class="cta" href="${ctaHref(phoneTel)}">${escHtml(copy.cta)}</a></div></nav>
+<div class="hero"><div><div class="eyebrow">${escHtml(copy.kind)} · ${escHtml(cityZip)}</div><h1>${(() => { const s = splitNameAccent(name); return s.lead ? `${escHtml(s.lead)} <em>${escHtml(s.tail)}</em>` : `<em>${escHtml(s.tail)}</em>`; })()}</h1><p class="lede">${escHtml(copy.tagline)}</p><div class="hero-actions"><a class="btn-fill" href="${ctaHref(phoneTel)}">${escHtml(copy.cta)}</a><a class="btn-link" href="#services">See what we offer</a></div></div><div class="hero-card"><div class="hc-eyebrow">Welcoming new patients</div><div class="hc-h">Same-week appointments. Real human at the front desk.</div><p class="hc-p">${cityZip?escHtml(cityZip):''}${phone?` · <a href="tel:${escHtml(phoneTel)}" style="color:${p.accent}">${escHtml(phone)}</a>`:''}</p></div></div>
+<section id="services"><div class="eyebrow">What we do</div><h2>Six things you can count on.</h2><p class="sub">Care that respects your time, your budget, and your history.</p><div class="services-grid">${services}</div></section>
+${pros.length?`<section id="team"><div class="eyebrow">Your providers</div><h2>The faces behind the practice.</h2><p class="sub">${pros.length} licensed providers, all practicing here.</p><div class="team-grid">${team}</div></section>`:''}
+<section><div class="contact-strip"><div class="eyebrow" style="color:${p.bg};opacity:0.75">Get in touch</div><h2>${escHtml(copy.cta)}</h2><p class="sub">${cityZip?escHtml(cityZip):''}</p>${phone?`<a class="btn-fill" href="tel:${escHtml(phoneTel)}">Call ${escHtml(phone)}</a>`:''}</div></section>
+</div><!-- variant=B warm-community · palette=${p.name} --></body></html>`;
+}
+
+// ─── Template C: Premium Wellness ─────────────────────────────────────────────
+// Dark luxury, gold/cream accents, spa/concierge feel.
+// Layout: full-bleed hero, oversized typography, 2-column service rhythm.
+function renderPreviewPremiumWellness(o, pros) {
+ const copy = PREVIEW_COPY[o.type] || PREVIEW_DEFAULT;
+ const name = titleCase(o.name || 'Our Practice');
+ const cityZip = [titleCase(o.city || ''), o.zip || ''].filter(Boolean).join(', ');
+ const phone = fmtPhone(o.phone);
+ const phoneTel = (o.phone || '').replace(/[^+0-9]/g,'');
+ const palettes = [
+ { bg:'#0E0F12', ink:'#F1ECE0', accent:'#C9A567', soft:'#1B1D22', card:'#16181D', name:'gold' },
+ { bg:'#0C1116', ink:'#E8E4D8', accent:'#9CA88F', soft:'#15191F', card:'#11161B', name:'sage-noir' },
+ { bg:'#10110D', ink:'#EDE8DA', accent:'#B98C5A', soft:'#1A1B16', card:'#15161F', name:'copper' },
+ { bg:'#0F0E10', ink:'#EFEAE0', accent:'#D4B5C6', soft:'#1A1820', card:'#161520', name:'rose-quartz' },
+ { bg:'#0A0F11', ink:'#E2E7DD', accent:'#7BAA8B', soft:'#121819', card:'#0F1517', name:'eucalyptus' },
+ ];
+ const p = palettes[pickSeed(o.id + 'C', palettes.length)];
+ const services = copy.services.map((s, i) =>
+ `<div class="svc"><div class="num">${String(i+1).padStart(2,'0')}</div><div class="svc-name">${escHtml(s)}</div><div class="svc-rule"></div></div>`
+ ).join('');
+ const team = pros.slice(0, 4).map(p => {
+ const initials = (p.full_name||'').split(/\s+/).slice(0,2).map(s=>s[0]).join('').toUpperCase()||'·';
+ return `<div class="member"><div class="ini">${escHtml(initials)}</div><div class="mn">${escHtml(titleCase(p.full_name))}</div><div class="mt">${escHtml(p.title||'')}</div><div class="ms">${escHtml(p.primary_specialty||'')}</div></div>`;
+ }).join('');
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escHtml(name)} — ${escHtml(copy.kind)}</title><style>
+*,*::before,*::after{box-sizing:border-box}html,body{margin:0;background:${p.bg};color:${p.ink};font:15px/1.7 ui-sans-serif,'Inter','Helvetica Neue',sans-serif;-webkit-font-smoothing:antialiased}
+.wrap{max-width:1240px;margin:0 auto;padding:0 32px}
+nav{display:flex;align-items:center;justify-content:space-between;padding:30px 0;border-bottom:1px solid ${p.soft}}
+.brand{font:500 16px/1 ui-serif,'Iowan Old Style','Didot','Bodoni 72',serif;letter-spacing:0.06em;text-transform:uppercase}
+.brand .accent{color:${p.accent};margin-left:8px;font-size:11px;letter-spacing:0.22em}
+.nav-r{display:flex;gap:32px;align-items:center}
+.nav-r a{color:${p.ink};opacity:0.7;text-decoration:none;font:500 12px/1 ui-sans-serif,sans-serif;letter-spacing:0.14em;text-transform:uppercase}
+.nav-r .book{border:1px solid ${p.accent};color:${p.accent};padding:11px 22px;letter-spacing:0.18em;border-radius:0;opacity:1}
+.hero{padding:120px 0 100px;text-align:center;position:relative}
+.hero::before{content:'';position:absolute;left:50%;top:80px;transform:translateX(-50%);width:1px;height:32px;background:${p.accent};opacity:0.55}
+.hero .label{font:500 11px/1 ui-sans-serif,sans-serif;letter-spacing:0.32em;text-transform:uppercase;color:${p.accent};margin-bottom:36px;display:block}
+.hero h1{font:400 clamp(54px,8vw,108px)/0.98 ui-serif,'Iowan Old Style','Didot','Bodoni 72',serif;margin:0 0 28px;letter-spacing:-0.025em}
+.hero h1 em{font-style:italic;color:${p.accent}}
+.hero p{font:400 18px/1.6 ui-sans-serif,sans-serif;color:${p.ink};opacity:0.72;max-width:560px;margin:0 auto 48px}
+.hero .actions{display:inline-flex;gap:0;align-items:center}
+.btn-cta{background:${p.accent};color:${p.bg};padding:18px 36px;text-decoration:none;font:600 12px/1 ui-sans-serif,sans-serif;letter-spacing:0.22em;text-transform:uppercase}
+.btn-secondary{color:${p.ink};text-decoration:none;font:500 12px/1 ui-sans-serif,sans-serif;letter-spacing:0.18em;text-transform:uppercase;padding:18px 28px;border-bottom:1px solid ${p.ink};margin-left:18px}
+section{padding:96px 0;border-top:1px solid ${p.soft}}
+.section-head{text-align:center;margin-bottom:64px}
+.section-head .ey{font:500 11px/1 ui-sans-serif,sans-serif;letter-spacing:0.32em;text-transform:uppercase;color:${p.accent};margin-bottom:18px;display:block}
+.section-head h2{font:400 clamp(32px,4.2vw,52px)/1.05 ui-serif,'Iowan Old Style',serif;margin:0;letter-spacing:-0.018em}
+.services-list{display:grid;grid-template-columns:repeat(2,1fr);gap:0;border-top:1px solid ${p.soft}}
+.svc{display:grid;grid-template-columns:60px 1fr 80px;gap:24px;align-items:center;padding:32px 28px;border-bottom:1px solid ${p.soft};border-right:1px solid ${p.soft}}
+.svc:nth-child(2n){border-right:0}
+.num{font:500 14px/1 ui-serif,serif;color:${p.accent};letter-spacing:0.05em}
+.svc-name{font:400 19px/1.3 ui-serif,serif;letter-spacing:-0.005em}
+.svc-rule{height:1px;background:${p.accent};opacity:0.4}
+.team-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:24px}
+.member{padding:36px 24px;background:${p.card};border:1px solid ${p.soft};text-align:center}
+.ini{width:64px;height:64px;border-radius:50%;background:${p.soft};color:${p.accent};font:500 20px/1 ui-serif,serif;display:flex;align-items:center;justify-content:center;margin:0 auto 22px;letter-spacing:0.04em}
+.mn{font:500 16px/1.3 ui-serif,serif;letter-spacing:-0.005em;margin-bottom:4px}
+.mt{font:500 11px/1 ui-sans-serif,sans-serif;letter-spacing:0.18em;text-transform:uppercase;color:${p.accent};margin-bottom:8px}
+.ms{font-size:13px;opacity:0.65}
+.contact{padding:120px 0;text-align:center;background:linear-gradient(180deg,${p.bg} 0%,${p.card} 100%);margin-top:0}
+.contact h2{font:400 clamp(40px,5vw,64px)/1 ui-serif,serif;margin:0 0 28px;letter-spacing:-0.02em}
+.contact p{font-size:17px;opacity:0.72;margin:0 0 40px;max-width:520px;margin-left:auto;margin-right:auto}
+.contact a.phone{color:${p.accent};text-decoration:none;font:500 36px/1 ui-serif,serif;display:block;margin-bottom:16px;letter-spacing:0.04em}
+.contact .city{font:500 11px/1 ui-sans-serif,sans-serif;letter-spacing:0.32em;text-transform:uppercase;opacity:0.55}
+@media(max-width:780px){.services-list,.team-grid{grid-template-columns:1fr}.svc{border-right:0}}
+</style></head><body><div class="wrap">
+<nav><div class="brand">${escHtml(name.split(/\s+/).slice(0,2).join(' '))}<span class="accent">${escHtml(copy.kind)}</span></div><div class="nav-r"><a href="#services">Services</a><a href="#team">Practitioners</a><a class="book" href="${ctaHref(phoneTel)}">${escHtml(copy.cta)}</a></div></nav>
+<div class="hero"><span class="label">${escHtml(cityZip || copy.kind)}</span><h1>${(() => { const s = splitNameAccent(name); return s.lead ? `${escHtml(s.lead)} <em>${escHtml(s.tail)}</em>` : `<em>${escHtml(s.tail)}</em>`; })()}</h1><p>${escHtml(copy.tagline)}</p><div class="actions"><a class="btn-cta" href="${ctaHref(phoneTel)}">${escHtml(copy.cta)}</a><a class="btn-secondary" href="#services">Explore →</a></div></div>
+<section id="services"><div class="section-head"><span class="ey">Services</span><h2>An intentional practice.</h2></div><div class="services-list">${services}</div></section>
+${pros.length?`<section id="team"><div class="section-head"><span class="ey">Practitioners</span><h2>Trained. Tenured. Trusted.</h2></div><div class="team-grid">${team}</div></section>`:''}
+<section class="contact" id="contact"><div class="section-head" style="margin-bottom:32px"><span class="ey">Begin</span><h2>${escHtml(copy.cta)}</h2></div><p>Discreet inquiries welcome. We respond within one business day.</p>${phone?`<a class="phone" href="tel:${escHtml(phoneTel)}">${escHtml(phone)}</a>`:''}<div class="city">${escHtml(cityZip)}</div></section>
+</div><!-- variant=C premium-wellness · palette=${p.name} --></body></html>`;
+}
+
+// ─── Template D: Wellness App ─────────────────────────────────────────────────
+// Server-rendered adaptation of 21st.dev's PulseFit hero (CSS keyframe carousel
+// instead of framer-motion; abstract gradient tiles instead of stock photos).
+// Centered hero on icy gradient wash, pill buttons, auto-scrolling service-card
+// rail. Reads "modern wellness coach app" — orthogonal to A/B/C.
+function renderPreviewWellnessApp(o, pros) {
+ const copy = PREVIEW_COPY[o.type] || PREVIEW_DEFAULT;
+ const name = titleCase(o.name || 'Our Practice');
+ const cityZip = [titleCase(o.city || ''), o.zip || ''].filter(Boolean).join(', ');
+ const phone = fmtPhone(o.phone);
+ const phoneTel = (o.phone || '').replace(/[^+0-9]/g,'');
+ const palettes = [
+ { top:'#E8F0FF', mid:'#F5F9FF', accent:'#1E3A8A', soft:'#DBEAFE', name:'icy-blue' },
+ { top:'#E8FFF1', mid:'#F2FFF7', accent:'#065F46', soft:'#D1FAE5', name:'mint' },
+ { top:'#FFEFE6', mid:'#FFF7F2', accent:'#9A3412', soft:'#FED7AA', name:'peach' },
+ { top:'#F1E8FF', mid:'#F7F2FF', accent:'#5B21B6', soft:'#E9D5FF', name:'lavender' },
+ { top:'#FFF8E1', mid:'#FFFCF0', accent:'#92400E', soft:'#FEE9B0', name:'soft-amber' },
+ ];
+ const p = palettes[pickSeed(o.id + 'D', palettes.length)];
+ // Tile color rotation — each service gets its own gradient swatch
+ const tileGradients = [
+ `linear-gradient(135deg, ${p.accent}, ${p.accent}66)`,
+ `linear-gradient(135deg, ${p.accent}cc, ${p.soft})`,
+ `linear-gradient(45deg, ${p.accent}, ${p.soft})`,
+ `linear-gradient(180deg, ${p.accent}aa, ${p.accent}33)`,
+ `linear-gradient(225deg, ${p.accent}, ${p.accent}55)`,
+ `linear-gradient(90deg, ${p.accent}99, ${p.soft})`,
+ ];
+ // Build 12 tiles (services duplicated for seamless loop)
+ const tilesOnce = copy.services.map((s, i) => {
+ const grad = tileGradients[i % tileGradients.length];
+ return `<div class="tile" style="background:${grad}"><div class="tile-cat">Service ${String(i+1).padStart(2,'0')}</div><div class="tile-title">${escHtml(s)}</div></div>`;
+ });
+ const tiles = [...tilesOnce, ...tilesOnce].join('');
+ const scrollDuration = Math.max(20, copy.services.length * 4); // sec
+ const tileCount = copy.services.length;
+ // Provider avatars — initials in circles, soft shadow
+ const teamAvatars = pros.slice(0, 5).map((pr, idx) => {
+ const initials = (pr.full_name||'').split(/\s+/).slice(0,2).map(s=>s[0]).join('').toUpperCase()||'·';
+ const shift = idx === 0 ? 0 : -10 * idx; // overlap
+ return `<div class="avatar" style="margin-left:${shift}px;background:${p.soft};color:${p.accent}">${escHtml(initials)}</div>`;
+ }).join('');
+ const teamCount = pros.length;
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escHtml(name)} — ${escHtml(copy.kind)}</title><style>
+*,*::before,*::after{box-sizing:border-box}html,body{margin:0;color:#1a1a1a;font:16px/1.6 'Inter',ui-sans-serif,system-ui,-apple-system,'SF Pro Text',sans-serif;-webkit-font-smoothing:antialiased;overflow-x:hidden}
+body{background:linear-gradient(180deg, ${p.top} 0%, ${p.mid} 50%, #FFFFFF 100%);min-height:100vh}
+header.nav{display:flex;align-items:center;justify-content:space-between;padding:32px clamp(20px,5vw,64px)}
+.logo{font:700 24px/1 'Inter',sans-serif;letter-spacing:-0.01em;color:#1a1a1a}
+.logo small{display:block;font:500 11px/1 'Inter',sans-serif;letter-spacing:0.16em;text-transform:uppercase;color:${p.accent};margin-top:6px}
+.nav-links{display:none}
+@media(min-width:900px){.nav-links{display:flex;gap:32px;align-items:center}}
+.nav-links a{color:#4a5568;font-size:16px;text-decoration:none}.nav-links a:hover{color:#1a1a1a}
+.nav-cta{background:#FFFFFF;border:1px solid #e2e8f0;padding:11px 22px;border-radius:9999px;font:500 15px/1 'Inter',sans-serif;color:#1a1a1a;text-decoration:none;box-shadow:0 2px 8px rgba(0,0,0,0.06)}
+.hero{padding:48px clamp(20px,5vw,64px) 32px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:32px;max-width:980px;margin:0 auto}
+.eyebrow{font:500 11px/1 'Inter',sans-serif;letter-spacing:0.32em;text-transform:uppercase;color:${p.accent};margin:0}
+.hero h1{font:700 clamp(40px,7vw,80px)/1.04 'Inter',sans-serif;letter-spacing:-0.025em;color:#1a1a1a;margin:0;max-width:880px}
+.hero .lede{font:400 clamp(17px,1.7vw,21px)/1.55 'Inter',sans-serif;color:#4a5568;max-width:600px;margin:0}
+.actions{display:flex;flex-direction:column;align-items:center;gap:14px}
+@media(min-width:560px){.actions{flex-direction:row}}
+.btn-primary{background:#1a1a1a;color:#fff;padding:18px 32px;border-radius:9999px;font:500 17px/1 'Inter',sans-serif;text-decoration:none;display:inline-flex;align-items:center;gap:10px;box-shadow:0 4px 16px rgba(0,0,0,0.18);transition:transform .15s ease}
+.btn-primary:hover{transform:translateY(-1px)}
+.btn-primary svg{width:20px;height:20px}
+.btn-secondary{background:transparent;color:#1a1a1a;padding:18px 30px;border-radius:9999px;font:500 17px/1 'Inter',sans-serif;text-decoration:none;border:1px solid #cbd5e0}
+.disclaimer{font:400 13px/1.5 'Inter',sans-serif;color:#718096;font-style:italic}
+.social-proof{display:flex;align-items:center;gap:14px;justify-content:center;margin-top:8px}
+.avatars{display:flex}
+.avatar{width:40px;height:40px;border-radius:50%;border:2px solid #fff;display:flex;align-items:center;justify-content:center;font:600 13px/1 'Inter',sans-serif;letter-spacing:0.04em}
+.sp-text{font:500 14px/1 'Inter',sans-serif;color:#4a5568}
+.carousel-wrap{position:relative;padding:60px 0 80px;overflow:hidden}
+.carousel-wrap::before,.carousel-wrap::after{content:'';position:absolute;top:0;bottom:0;width:120px;z-index:2;pointer-events:none}
+.carousel-wrap::before{left:0;background:linear-gradient(90deg,#FFFFFF 0%,rgba(255,255,255,0) 100%)}
+.carousel-wrap::after{right:0;background:linear-gradient(270deg,#FFFFFF 0%,rgba(255,255,255,0) 100%)}
+.carousel{display:flex;gap:24px;padding-left:24px;animation:scroll ${scrollDuration}s linear infinite;width:max-content}
+@keyframes scroll{from{transform:translateX(0)}to{transform:translateX(calc(-1 * (356px + 24px) * ${tileCount}))}}
+.tile{flex-shrink:0;width:356px;height:480px;border-radius:24px;box-shadow:0 12px 36px rgba(0,0,0,0.14);position:relative;overflow:hidden;display:flex;flex-direction:column;justify-content:flex-end;padding:28px;color:#fff}
+.tile::before{content:'';position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 30%,rgba(0,0,0,0.45) 100%)}
+.tile-cat{position:relative;font:500 12px/1 'Inter',sans-serif;color:rgba(255,255,255,0.85);text-transform:uppercase;letter-spacing:0.14em;margin-bottom:10px}
+.tile-title{position:relative;font:600 26px/1.25 'Inter',sans-serif;color:#fff;letter-spacing:-0.005em}
+.providers-section{padding:40px clamp(20px,5vw,64px) 80px;max-width:1180px;margin:0 auto;text-align:center}
+.providers-section h2{font:600 clamp(28px,3.2vw,40px)/1.1 'Inter',sans-serif;letter-spacing:-0.018em;margin:0 0 14px}
+.providers-section .sub{color:#4a5568;font-size:17px;margin:0 0 40px}
+.providers-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px;max-width:760px;margin:0 auto}
+@media(min-width:780px){.providers-grid{grid-template-columns:repeat(3,1fr)}}
+.provider-pill{background:#fff;border:1px solid #e2e8f0;border-radius:18px;padding:24px 22px;display:flex;align-items:center;gap:16px;box-shadow:0 4px 14px rgba(0,0,0,0.04)}
+.provider-pill .ini{width:48px;height:48px;border-radius:50%;background:${p.soft};color:${p.accent};display:flex;align-items:center;justify-content:center;font:600 16px/1 'Inter',sans-serif;flex-shrink:0;letter-spacing:0.02em}
+.pn{font:600 15px/1.3 'Inter',sans-serif;text-align:left}
+.ps{font:400 12.5px/1.3 'Inter',sans-serif;color:#718096;text-align:left;margin-top:3px}
+.cta-section{background:#fff;border-radius:32px;padding:64px 32px;margin:0 clamp(20px,5vw,64px) 60px;text-align:center;box-shadow:0 16px 48px rgba(0,0,0,0.06)}
+.cta-section h2{font:600 clamp(32px,3.6vw,46px)/1.05 'Inter',sans-serif;letter-spacing:-0.02em;margin:0 0 18px}
+.cta-section p{color:#4a5568;font-size:17px;margin:0 0 32px}
+.cta-section .city{font:500 11px/1 'Inter',sans-serif;letter-spacing:0.32em;text-transform:uppercase;color:#718096;margin-top:24px}
+@media(prefers-reduced-motion:reduce){.carousel{animation:none}}
+</style></head><body>
+<header class="nav"><div class="logo">${escHtml(name.split(/\s+/).slice(0,2).join(' '))}<small>${escHtml(copy.kind)}</small></div><div class="nav-links"><a href="#services">Services</a><a href="#team">Providers</a><a href="#contact">Contact</a></div><a class="nav-cta" href="${ctaHref(phoneTel)}">${escHtml(copy.cta)}</a></header>
+<section class="hero">
+ <p class="eyebrow">${escHtml(cityZip || copy.kind)}</p>
+ <h1>${escHtml(name)}</h1>
+ <p class="lede">${escHtml(copy.tagline)}</p>
+ <div class="actions">
+ <a class="btn-primary" href="${ctaHref(phoneTel)}">${escHtml(copy.cta)}<svg viewBox="0 0 20 20" fill="none"><path d="M7 10H13M13 10L10 7M13 10L10 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
+ <a class="btn-secondary" href="#services">Browse services</a>
+ </div>
+ <p class="disclaimer">No commitment. Most insurance accepted.</p>
+ ${pros.length ? `<div class="social-proof"><div class="avatars">${teamAvatars}</div><span class="sp-text">${teamCount} licensed provider${teamCount===1?'':'s'} practicing here</span></div>` : ''}
+</section>
+<div class="carousel-wrap" id="services" aria-label="Services carousel"><div class="carousel">${tiles}</div></div>
+${pros.length?`<section class="providers-section" id="team"><p class="eyebrow">The team</p><h2>Real people. Real names.</h2><p class="sub">Booking with us means seeing one of these providers — not a stranger.</p><div class="providers-grid">${pros.slice(0,6).map(pr=>{const ini=(pr.full_name||'').split(/\s+/).slice(0,2).map(s=>s[0]).join('').toUpperCase()||'·';return `<div class="provider-pill"><div class="ini">${escHtml(ini)}</div><div><div class="pn">${escHtml(titleCase(pr.full_name))}${pr.title?', '+escHtml(pr.title):''}</div><div class="ps">${escHtml(pr.primary_specialty||'')}</div></div></div>`}).join('')}</div></section>`:''}
+<section class="cta-section" id="contact"><p class="eyebrow">Get started</p><h2>${escHtml(copy.cta)}</h2><p>${cityZip?escHtml(cityZip):'Welcoming new patients'}</p>${phone?`<a class="btn-primary" href="tel:${escHtml(phoneTel)}">Call ${escHtml(phone)}<svg viewBox="0 0 20 20" fill="none"><path d="M7 10H13M13 10L10 7M13 10L10 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></a>`:''}<div class="city">${escHtml(cityZip)}</div></section>
+<!-- variant=D wellness-app · palette=${p.name} · adapted from 21st.dev PulseFit hero (server-rendered, no React/framer-motion runtime) -->
+</body></html>`;
+}
+
+// ─── Template E: Editorial Brutalist ──────────────────────────────────────────
+// Adapted from 21st.dev "Hero 03" by aliimam.in (similarity 0.105, the only
+// genuinely orthogonal direction the re-queries surfaced). Layout primitive:
+// asymmetric three-line ALL-CAPS statement (10rem light) flanked by 12-13px
+// italic mono meta-text columns; vertical rotated right-edge badge; hairline
+// separators; dotted-grid background. NO rounded chrome. NO gradients.
+// Mood derivation per Paper.design: clinical chart paper at dawn — bone, ink,
+// antiseptic, vitals-red, x-ray cyan.
+const BRUTALIST_HEADLINES = {
+ hospital: ['WORLD', 'CLASS', 'CARE'],
+ medical_group: ['CARE', 'THAT', 'KNOWS YOU'],
+ clinic: ['WALK IN', 'SAME', 'WEEK'],
+ dental_office: ['QUIET', 'ROOMS', 'HONEST PRICES'],
+ optometrist_office: ['BETTER', 'VISION', 'BETTER DAYS'],
+ surgery_center: ['OUTPATIENT', 'CARE', 'HOSPITAL EXPERTISE'],
+ fqhc: ['QUALITY', 'CARE', 'EVERYONE'],
+ hospice: ['COMFORT', 'DIGNITY', 'PRESENCE'],
+ rcfe: ['DIGNIFIED', 'HOMES', 'NEXT CHAPTER'],
+ ccrc: ['INDEPENDENCE', 'TODAY', 'CARE TOMORROW'],
+ adult_residential: ['HOME', 'WHERE EVERY', 'RESIDENT KNOWN'],
+ adult_day_program: ['ENGAGED', 'DAYS', 'PEACE OF MIND'],
+ home_care_agency: ['CARE', 'IN THE', 'HOME YOU LOVE'],
+ home_health: ['SKILLED', 'CLINICAL', 'AT HOME'],
+ nursing_facility: ['ROUND', 'THE CLOCK', 'SKILLED CARE'],
+ tcm_clinic: ['TIME', 'HONORED', 'MODERN LIVES'],
+ acupuncture_clinic: ['STILLNESS', 'IS A', 'TREATMENT'],
+ chiropractor_office: ['MOVE', 'BETTER', 'LIVE BETTER'],
+};
+const BRUTALIST_DEFAULT_HEADLINE = ['CARE', 'THAT', 'RESPECTS YOU'];
+const BRUTALIST_BADGES = {
+ hospital: 'EMERGENCY READY', medical_group: 'INSURED PATIENTS', clinic: 'WALK-IN HOURS',
+ dental_office: 'HSA / FSA', optometrist_office: 'CONTACTS IN 7 DAYS',
+ surgery_center: 'BOARD CERTIFIED', fqhc: 'SLIDING SCALE', hospice: '24/7 ON-CALL',
+ rcfe: 'TOURS BY APPT', ccrc: 'PRIORITY ADMIT', adult_residential: 'TOUR WELCOMED',
+ adult_day_program: 'TRANSPORT INCL', home_care_agency: 'BACKGROUND CHECKED',
+ home_health: 'MEDICARE BILLED', nursing_facility: 'JOINT COMMISSION',
+ tcm_clinic: 'LICENSED L.AC', acupuncture_clinic: 'LICENSED L.AC',
+ chiropractor_office: 'D.C. LICENSED',
+};
+function renderPreviewEditorialBrutalist(o, pros) {
+ const copy = PREVIEW_COPY[o.type] || PREVIEW_DEFAULT;
+ const name = titleCase(o.name || 'Our Practice');
+ const cityZip = [titleCase(o.city || ''), o.zip || ''].filter(Boolean).join(', ');
+ const phone = fmtPhone(o.phone);
+ const phoneTel = (o.phone || '').replace(/[^+0-9]/g,'');
+ const palettes = [
+ // Each palette is mood-derived (Paper.design rule): physical scene → hex
+ { bg:'#F4F1EA', ink:'#0A0A0A', meta:'#5C5C5C', accent:'#D63F2A', cool:'#7AB8C4', mood:'bone', dark:false }, // chart paper at dawn
+ { bg:'#F1F4F0', ink:'#13241D', meta:'#506257', accent:'#1B5E20', cool:'#A8C8B5', mood:'chart', dark:false }, // botanical
+ { bg:'#F8F8F4', ink:'#0F1115', meta:'#5A5A5A', accent:'#1A4D8C', cool:'#B8CCE0', mood:'clinical', dark:false }, // mineral
+ { bg:'#F2EEE3', ink:'#1A0F0A', meta:'#6B4F35', accent:'#C2410C', cool:'#FBE3B3', mood:'dawn', dark:false }, // candlelit
+ { bg:'#1A1B1F', ink:'#E8E8E8', meta:'#888888', accent:'#FF6B5B', cool:'#65BAC8', mood:'radiograph', dark:true }, // nocturnal x-ray
+ ];
+ const p = palettes[pickSeed(o.id + 'E', palettes.length)];
+ const headline = BRUTALIST_HEADLINES[o.type] || BRUTALIST_DEFAULT_HEADLINE;
+ const badge = BRUTALIST_BADGES[o.type] || 'NEW PATIENTS';
+ // Meta paragraphs flanking the giant headlines — derived from copy + cityZip
+ const metaLeft = `Located ${cityZip || 'in your community'}. ${escHtml((copy.tagline || '').split('.')[0])}.`;
+ const metaRight = `Open to new patients. ${pros.length ? pros.length + ' licensed providers practicing here.' : 'Insurance-friendly. Same-week appointments.'}`;
+ const services = copy.services.map((s, i) =>
+ `<div class="svc"><span class="svc-num">${String(i+1).padStart(2,'0')}</span><span class="svc-name">${escHtml(s)}</span><span class="svc-rule"></span></div>`
+ ).join('');
+ const team = pros.slice(0, 8).map((pr, i) => {
+ return `<div class="prov"><span class="prov-idx">${String(i+1).padStart(2,'0')}</span><span class="prov-name">${escHtml(titleCase(pr.full_name))}${pr.title?', '+escHtml(pr.title):''}</span><span class="prov-spec">${escHtml(pr.primary_specialty || '')}</span></div>`;
+ }).join('');
+ // Heart SVG inline at accent color — used as the mid-line glyph that breaks the headline
+ const heartIcon = `<svg class="heart" viewBox="0 0 24 24" fill="${p.accent}" aria-hidden="true"><path d="M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5"/></svg>`;
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escHtml(name)} — ${escHtml(copy.kind)}</title><style>
+*,*::before,*::after{box-sizing:border-box}html,body{margin:0;background:${p.bg};color:${p.ink};font:14px/1.5 ui-monospace,'IBM Plex Mono','SF Mono','Menlo',monospace;-webkit-font-smoothing:antialiased;overflow-x:hidden;background-image:radial-gradient(circle, ${p.cool}33 1px, transparent 1px);background-size:24px 24px}
+.wrap{max-width:1440px;margin:0 auto;padding:0 32px;position:relative}
+header.brut{display:grid;grid-template-columns:1fr auto 1fr;gap:24px;align-items:center;padding:28px 0;border-bottom:1px solid ${p.ink}}
+.brand{font:600 13px/1 ui-monospace,monospace;letter-spacing:0.16em;text-transform:uppercase}
+.brand .org{font-weight:400;opacity:0.7;margin-left:12px}
+.eyebrow-mid{font:400 12px/1 ui-monospace,monospace;letter-spacing:0.18em;text-transform:uppercase;color:${p.meta};text-align:center}
+.contact-top{font:400 12px/1 ui-monospace,monospace;letter-spacing:0.08em;text-align:right;color:${p.ink}}
+.contact-top a{color:${p.ink};text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:3px}
+.hero{padding:60px 0 80px;display:grid;grid-template-columns:1fr;gap:6px;position:relative}
+.hero-line{display:grid;grid-template-columns:240px 1fr 240px;gap:32px;align-items:center}
+.hero-line:nth-child(2){grid-template-columns:1fr 240px}
+.hero-line:nth-child(3){grid-template-columns:240px 1fr}
+.meta-l,.meta-r{font:italic 400 13px/1.55 ui-serif,'Iowan Old Style',Georgia,serif;color:${p.meta};max-width:240px}
+.meta-r{text-align:right;justify-self:end}
+.bigword{font:300 clamp(54px,11vw,160px)/0.92 'Helvetica Neue','Inter',ui-sans-serif,sans-serif;letter-spacing:-0.02em;text-transform:uppercase;color:${p.ink};display:flex;align-items:center;gap:18px;flex-wrap:nowrap;white-space:nowrap;overflow:hidden}
+.bigword .heart{width:clamp(40px,8vw,120px);height:clamp(40px,8vw,120px);flex-shrink:0}
+.hero-line:nth-child(2) .bigword{justify-content:flex-start}
+.hero-line:nth-child(3) .bigword{justify-content:flex-end;text-align:right}
+.hairline{height:1px;background:${p.ink};margin:60px 0;width:100%;opacity:0.85}
+section{padding:60px 0;border-top:1px solid ${p.ink}}
+.section-head{display:grid;grid-template-columns:240px 1fr;gap:32px;align-items:baseline;margin-bottom:40px}
+.s-label{font:600 11px/1 ui-monospace,monospace;letter-spacing:0.32em;text-transform:uppercase;color:${p.accent}}
+.s-title{font:300 clamp(28px,4.4vw,64px)/1 'Helvetica Neue','Inter',sans-serif;letter-spacing:-0.018em;text-transform:uppercase}
+.svc{display:grid;grid-template-columns:60px 1fr 1fr;gap:24px;align-items:center;padding:18px 0;border-top:1px solid ${p.ink}33}
+.svc:first-child{border-top:0}
+.svc-num{font:400 13px/1 ui-monospace,monospace;letter-spacing:0.05em;color:${p.accent}}
+.svc-name{font:400 19px/1 'Helvetica Neue','Inter',sans-serif;letter-spacing:-0.005em}
+.svc-rule{height:1px;background:${p.ink};opacity:0.45;justify-self:end;width:60%}
+.prov{display:grid;grid-template-columns:60px 1.4fr 1fr;gap:24px;padding:14px 0;border-top:1px solid ${p.ink}33;align-items:baseline}
+.prov:first-child{border-top:0}
+.prov-idx{font:400 13px/1 ui-monospace,monospace;color:${p.accent}}
+.prov-name{font:500 16px/1.3 'Helvetica Neue','Inter',sans-serif}
+.prov-spec{font:italic 400 13px/1.3 ui-serif,Georgia,serif;color:${p.meta}}
+.contact-bar{display:grid;grid-template-columns:240px 1fr 240px;gap:32px;align-items:end;padding:60px 0 80px;border-top:1px solid ${p.ink}}
+.cb-label{font:600 11px/1 ui-monospace,monospace;letter-spacing:0.32em;text-transform:uppercase;color:${p.accent}}
+.cb-action{font:300 clamp(36px,6vw,84px)/1 'Helvetica Neue','Inter',sans-serif;letter-spacing:-0.02em;text-transform:uppercase}
+.cb-action a{color:${p.ink};text-decoration:underline;text-decoration-thickness:2px;text-underline-offset:8px}
+.cb-meta{font:400 12px/1.55 ui-monospace,monospace;color:${p.meta};text-align:right;justify-self:end;max-width:240px}
+.side-rail{position:fixed;right:8px;top:50%;transform:translateY(-50%) rotate(180deg);writing-mode:vertical-rl;background:${p.ink};color:${p.bg};padding:18px 8px;font:600 11px/1 ui-monospace,monospace;letter-spacing:0.32em;text-transform:uppercase;z-index:10}
+@media(max-width:980px){
+ .hero-line,.hero-line:nth-child(2),.hero-line:nth-child(3){grid-template-columns:1fr;gap:12px}
+ .meta-r{text-align:left;justify-self:start}
+ .hero-line:nth-child(2) .bigword,.hero-line:nth-child(3) .bigword{justify-content:flex-start;text-align:left}
+ .section-head,.contact-bar{grid-template-columns:1fr;gap:14px}
+ .cb-meta{text-align:left;justify-self:start}
+ .svc,.prov{grid-template-columns:40px 1fr;row-gap:6px}
+ .svc-rule,.svc:nth-child(n+1) .svc-rule{display:none}
+ .prov-spec{grid-column:2}
+ .side-rail{display:none}
+}
+</style></head><body><div class="wrap">
+<header class="brut"><div class="brand">${escHtml(name.split(/\s+/).slice(0,3).join(' '))}<span class="org">${escHtml(copy.kind)}</span></div><div class="eyebrow-mid">${escHtml(cityZip || 'CALIFORNIA')}</div><div class="contact-top">${phone?`<a href="tel:${escHtml(phoneTel)}">${escHtml(phone)}</a>`:'<span style="opacity:.6">contact below</span>'}</div></header>
+<div class="hero" aria-label="${escHtml(name)}">
+ <div class="hero-line"><div class="meta-l">${metaLeft}</div><div class="bigword">${escHtml(headline[0])}</div><div></div></div>
+ <div class="hero-line"><div class="bigword">${escHtml(headline[1].split(' ')[0])}${heartIcon}${escHtml(headline[1].split(' ').slice(1).join(' ') || 'CARE')}</div><div class="meta-r">${metaRight}</div></div>
+ <div class="hero-line"><div></div><div class="bigword">${escHtml(headline[2])}</div></div>
+</div>
+<section id="services"><div class="section-head"><span class="s-label">Services / 0${copy.services.length}</span><h2 class="s-title">What we do.</h2></div>${services}</section>
+${pros.length?`<section id="team"><div class="section-head"><span class="s-label">Providers / ${String(pros.length).padStart(2,'0')}</span><h2 class="s-title">Real names. Real licenses.</h2></div>${team}</section>`:''}
+<div class="contact-bar" id="contact"><div class="cb-label">Contact / 01</div><div class="cb-action">${phone?`<a href="tel:${escHtml(phoneTel)}">${escHtml(copy.cta)}</a>`:escHtml(copy.cta)}</div><div class="cb-meta">${escHtml(name)}<br>${escHtml(cityZip)}${phone?`<br>${escHtml(phone)}`:''}</div></div>
+</div>
+<aside class="side-rail" aria-label="${escHtml(badge)}">${escHtml(badge)}</aside>
+<!-- variant=E editorial-brutalist · palette=${p.mood} · adapted from 21st.dev Hero 03 (aliimam.in, similarity 0.105) — server-rendered, no React/framer-motion runtime -->
+</body></html>`;
+}
+
+// 4-up index showing all variants for the same org. Iframe each variant so the
+// pitch flow can show "here are 4 directions for your site" in one view.
+function renderPreviewVariantIndex(o) {
+ const id = o.id;
+ const name = titleCase(o.name || 'Practice');
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escHtml(name)} — 5 design directions</title><style>
+*,*::before,*::after{box-sizing:border-box}body{margin:0;background:#0A0A0F;color:#E5E7EB;font:14px/1.5 ui-sans-serif,system-ui,sans-serif;-webkit-font-smoothing:antialiased}
+header{padding:24px 32px;background:#13131C;border-bottom:1px solid #1F2030;display:flex;align-items:center;gap:24px}
+header h1{margin:0;font-size:18px;font-weight:600}
+header .meta{color:#7A7B91;font-size:13px}
+.grid{display:grid;grid-template-columns:repeat(5,1fr);gap:0;height:calc(100vh - 70px);background:#1F2030}
+.col{display:flex;flex-direction:column;background:#0A0A0F;min-width:0}
+.tag{padding:14px 18px;background:#13131C;border-bottom:1px solid #1F2030;display:flex;align-items:center;justify-content:space-between}
+.tag .name{font-weight:600;font-size:13px}
+.tag .name span{color:#7A7B91;font-weight:400;margin-left:6px}
+.tag a{color:#10b981;text-decoration:none;font-size:12px}
+.frame{flex:1;background:#fff;overflow:hidden}
+iframe{width:100%;height:100%;border:0;background:#fff}
+@media(max-width:1500px){.grid{grid-template-columns:repeat(3,1fr);height:auto}.col{height:60vh}}
+@media(max-width:1000px){.grid{grid-template-columns:repeat(2,1fr)}.col{height:55vh}}
+@media(max-width:680px){.grid{grid-template-columns:1fr}.col{height:80vh}}
+</style></head><body>
+<header><h1>${escHtml(name)}</h1><span class="meta">org #${escHtml(String(id))} · 5 design directions</span></header>
+<div class="grid">
+ <div class="col"><div class="tag"><div class="name">A <span>Modern Clinical</span></div><a href="/preview/${id}/a" target="_blank">open ↗</a></div><div class="frame"><iframe src="/preview/${id}/a" loading="lazy"></iframe></div></div>
+ <div class="col"><div class="tag"><div class="name">B <span>Warm Community</span></div><a href="/preview/${id}/b" target="_blank">open ↗</a></div><div class="frame"><iframe src="/preview/${id}/b" loading="lazy"></iframe></div></div>
+ <div class="col"><div class="tag"><div class="name">C <span>Premium Wellness</span></div><a href="/preview/${id}/c" target="_blank">open ↗</a></div><div class="frame"><iframe src="/preview/${id}/c" loading="lazy"></iframe></div></div>
+ <div class="col"><div class="tag"><div class="name">D <span>Wellness App (21st.dev)</span></div><a href="/preview/${id}/d" target="_blank">open ↗</a></div><div class="frame"><iframe src="/preview/${id}/d" loading="lazy"></iframe></div></div>
+ <div class="col"><div class="tag"><div class="name">E <span>Editorial-Brutalist (21st.dev)</span></div><a href="/preview/${id}/e" target="_blank">open ↗</a></div><div class="frame"><iframe src="/preview/${id}/e" loading="lazy"></iframe></div></div>
+</div>
+</body></html>`;
+}
+
function renderDashboard(c) {
return `<!doctype html><html><head><meta charset="utf-8">
<title>Professional Directory · admin</title>
<style>
- body{font:14px/1.5 system-ui;-webkit-font-smoothing:antialiased;margin:0;background:#0c0f1a;color:#f0f0f5}
- header{padding:32px;background:#131829;border-bottom:1px solid #2a3048}
- h1{margin:0 0 6px 0;font-size:22px}
- .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;padding:24px}
- .card{background:#131829;border:1px solid #2a3048;border-radius:12px;padding:18px}
- .num{font-size:28px;font-weight:600;color:#10b981}
- .lbl{color:#a1a1b5;font-size:12px;text-transform:uppercase;letter-spacing:.5px;margin-top:6px}
- pre{background:#1c2237;padding:12px;border-radius:8px;overflow:auto}
- a{color:#10b981}
- .links{padding:0 24px 32px}
+ :root { --bg:#0c0f1a; --panel:#131829; --border:#2a3048; --fg:#f0f0f5; --muted:#a1a1b5; --accent:#10b981; }
+ *,*::before,*::after { box-sizing: border-box; }
+ body { font:14px/1.5 system-ui,-apple-system,sans-serif; -webkit-font-smoothing:antialiased; margin:0; background:var(--bg); color:var(--fg); }
+ header { padding:24px 28px; background:var(--panel); border-bottom:1px solid var(--border); display:flex; align-items:baseline; gap:24px; flex-wrap:wrap; }
+ h1 { margin:0; font-size:20px; }
+ .stats { display:flex; gap:18px; flex-wrap:wrap; color:var(--muted); font-size:13px; }
+ .stats b { color:var(--fg); font-weight:600; }
+ a { color:var(--accent); text-decoration:none; }
+ a:hover { text-decoration:underline; }
+
+ .controls { padding:16px 28px; background:var(--panel); border-bottom:1px solid var(--border); display:flex; align-items:center; gap:18px; flex-wrap:wrap; }
+ .seg { display:inline-flex; background:var(--bg); border:1px solid var(--border); border-radius:8px; overflow:hidden; }
+ .seg button { background:transparent; color:var(--muted); border:0; padding:7px 14px; font:inherit; cursor:pointer; }
+ .seg button.active { background:var(--accent); color:#04130c; font-weight:600; }
+ .slider-group { display:flex; align-items:center; gap:10px; }
+ .slider-group label { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.5px; }
+ .slider-group input[type=range] { accent-color:var(--accent); width:160px; }
+ .slider-group .val { color:var(--fg); font-variant-numeric:tabular-nums; min-width:24px; text-align:right; }
+ select, input[type=text] { background:var(--bg); color:var(--fg); border:1px solid var(--border); border-radius:6px; padding:6px 10px; font:inherit; }
+ .pager { margin-left:auto; color:var(--muted); }
+ .pager button { background:var(--bg); border:1px solid var(--border); color:var(--fg); border-radius:6px; padding:6px 10px; cursor:pointer; }
+ .pager button:disabled { opacity:.4; cursor:not-allowed; }
+
+ /* Grid view */
+ #results.grid { display:grid; gap:14px; padding:18px 28px; grid-template-columns:repeat(var(--cols, 5), minmax(0, 1fr)); }
+ #results.grid .item { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:14px; min-height:88px; }
+ #results.grid .item .name { font-weight:600; font-size:14px; margin-bottom:4px; }
+ #results.grid .item .meta { color:var(--muted); font-size:12px; line-height:1.45; }
+ #results.grid .item .badge { display:inline-block; padding:1px 6px; border-radius:4px; background:#1c2237; color:var(--accent); font-size:11px; margin-right:4px; }
+
+ /* Map view (Leaflet) */
+ #results.map { padding:0; height: calc(100vh - 200px); }
+ #results.map #leaflet { width:100%; height:100%; background:var(--panel); }
+ .leaflet-popup-content { font: 13px/1.4 system-ui; color:#0c0f1a; min-width: 200px; }
+ .leaflet-popup-content .pname { font-weight:600; font-size:14px; margin-bottom:4px; }
+ .leaflet-popup-content .needs { color:#b45309; font-weight:600; }
+ .leaflet-popup-content .has { color:#737373; }
+
+ /* ZIP heat panel */
+ #zip-heat { padding:0 28px 18px; display:none; }
+ #results.map ~ #zip-heat { display:block; }
+ #zip-heat h3 { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.5px; margin:14px 0 8px; }
+ #zip-heat .ziprow { display:inline-flex; align-items:center; gap:8px; padding:6px 10px; margin:0 6px 6px 0; background:var(--panel); border:1px solid var(--border); border-radius:6px; font-variant-numeric:tabular-nums; font-size:12px; cursor:pointer; }
+ #zip-heat .ziprow b { color:#facc15; }
+ #zip-heat .ziprow span { color:var(--muted); }
+
+ /* List view (spreadsheet-style table) */
+ #results.list { padding:0 28px 28px; }
+ #results.list table { width:100%; border-collapse:collapse; font-size:13px; }
+ #results.list th { text-align:left; padding:10px 12px; background:var(--panel); border-bottom:1px solid var(--border); position:sticky; top:0; font-weight:600; color:var(--muted); text-transform:uppercase; font-size:11px; letter-spacing:.5px; }
+ #results.list th.sortable { cursor:pointer; user-select:none; }
+ #results.list th.sortable:hover { color:var(--fg); }
+ #results.list th.sortable.asc::after { content:" ↑"; color:var(--accent); }
+ #results.list th.sortable.desc::after { content:" ↓"; color:var(--accent); }
+ #results.list td { padding:8px 12px; border-bottom:1px solid var(--border); white-space:nowrap; max-width:280px; overflow:hidden; text-overflow:ellipsis; }
+ #results.list tr:hover td { background:var(--panel); }
+ #results.list td a.email { color:var(--accent); }
+
+ /* Map empty-state overlay */
+ #results.map .map-empty { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; color:#fbbf24; background:rgba(12,15,26,0.85); font-size:15px; font-weight:600; padding:24px; text-align:center; line-height:1.5; z-index:500; pointer-events:none; }
+ #results.map .map-loading { position:absolute; top:14px; right:14px; background:var(--panel); border:1px solid var(--border); color:var(--muted); padding:6px 12px; border-radius:6px; font-size:12px; z-index:500; }
+
+ .empty { padding:40px 28px; color:var(--muted); text-align:center; }
</style></head><body>
-<header><h1>Professional Directory</h1><div style="color:#a1a1b5">LA County · last updated ${c.last_update || '—'}</div></header>
-<div class="grid">
- <div class="card"><div class="num">${c.professionals}</div><div class="lbl">Professionals</div></div>
- <div class="card"><div class="num">${c.organizations}</div><div class="lbl">Organizations</div></div>
- <div class="card"><div class="num">${c.hospitals}</div><div class="lbl">Hospitals</div></div>
- <div class="card"><div class="num">${c.specialties}</div><div class="lbl">Specialties (NUCC)</div></div>
- <div class="card"><div class="num">${c.la_zips}</div><div class="lbl">LA County ZIPs</div></div>
- <div class="card"><div class="num">${c.raw_records}</div><div class="lbl">Raw records (provenance)</div></div>
-</div>
-<div class="links">
- <h3>Quick links</h3>
- <ul>
- <li><a href="/professionals?limit=25">/professionals?limit=25</a></li>
- <li><a href="/organizations?type=hospital">/organizations?type=hospital</a></li>
- <li><a href="/organizations?city=Beverly%20Hills">/organizations?city=Beverly Hills</a></li>
- <li><a href="/search?q=cardiology">/search?q=cardiology</a></li>
- <li><a href="/export.csv?entity=professionals">/export.csv?entity=professionals</a></li>
- <li><a href="/health">/health</a></li>
- </ul>
+
+<header>
+ <h1>Professional Directory</h1>
+ <div class="stats">
+ <span><b>${c.professionals}</b> professionals</span>
+ <span><b>${c.organizations}</b> orgs</span>
+ <span><b>${c.hospitals}</b> hospitals</span>
+ <span><b>${c.specialties}</b> specialties</span>
+ <span><b>${c.la_zips}</b> LA ZIPs</span>
+ <span style="margin-left:auto"><a href="/health">/health</a> · <a href="/export.csv?entity=professionals">export</a></span>
+ </div>
+</header>
+
+<div class="controls">
+ <div class="seg" id="entity-toggle">
+ <button data-entity="professionals" class="active">Professionals</button>
+ <button data-entity="organizations">Organizations</button>
+ </div>
+
+ <select id="category-select" title="Filter to a small-biz category (Organizations only)">
+ <option value="">All categories</option>
+ <option value="cosmetology">Cosmetology · salons, barbers, nails, spa</option>
+ <option value="chiropractic">Chiropractic</option>
+ <option value="chinese_medicine">Chinese medicine · acupuncture, TCM, herbal</option>
+ <option value="dental">Dental offices</option>
+ <option value="optometry">Optometry · eye doctors, opticals</option>
+ <option value="fitness">Fitness · yoga, pilates, gyms</option>
+ <option value="tattoo">Tattoo · ink, piercing</option>
+ <option value="pets">Pets · vets, groomers, pet shops</option>
+ <option value="medical">Medical · hospitals, clinics, FQHC, etc.</option>
+ </select>
+
+ <select id="prospect-select" title="Prospect filter — who needs a website?">
+ <option value="">Any (all orgs)</option>
+ <option value="outreach">📬 Ready-to-pitch (has email + not contacted) ★★★</option>
+ <option value="needs">Needs website (no site)</option>
+ <option value="has">Has website</option>
+ <option value="phone">Has phone (any site)</option>
+ <option value="phone_needs">Has phone + needs website ★</option>
+ <option value="domain_avail">Domain available + needs site ★★</option>
+ <option value="warmest">Warmest leads (score ≥ 80) ★★★</option>
+ <option value="has_email">✉ Has email on file</option>
+ <option value="starred">⭐ Starred only</option>
+ <option value="uncontacted">Not yet contacted</option>
+ </select>
+
+ <select id="sort-select" title="Sort">
+ <option value="score">Sort: lead score ★</option>
+ <option value="prospects">Sort: prospects first</option>
+ <option value="name">Sort: name</option>
+ <option value="zip">Sort: ZIP</option>
+ <option value="has_website">Sort: has-website first</option>
+ </select>
+
+ <label style="color:var(--muted);font-size:12px;display:flex;align-items:center;gap:6px;cursor:pointer">
+ <input type="checkbox" id="hide-chains" checked> hide chains
+ </label>
+
+ <a id="export-link" href="/outreach.csv" style="font-size:12px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;color:var(--accent);">⬇ CSV</a>
+
+ <div class="seg" id="view-toggle">
+ <button data-view="grid" class="active">Grid</button>
+ <button data-view="list">List</button>
+ <button data-view="map">Map</button>
+ </div>
+
+ <div class="slider-group" id="grid-controls">
+ <label for="grid-cols">Per row</label>
+ <input type="range" min="3" max="12" value="5" id="grid-cols">
+ <span class="val" id="grid-cols-val">5</span>
+ </div>
+
+ <input type="text" id="filter-q" placeholder="Filter by name…" style="width:180px">
+
+ <div class="pager">
+ <button id="prev">← Prev</button>
+ <span id="pageinfo" style="margin:0 8px;font-variant-numeric:tabular-nums">0–0</span>
+ <button id="next">Next →</button>
+ </div>
</div>
+
+<div id="results" class="grid"></div>
+<div id="zip-heat"></div>
+
+<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
+<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
+<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
+<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
+<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
+
+<script>
+(() => {
+ const $ = (s) => document.querySelector(s);
+ const results = $('#results');
+ const gridColsEl = $('#grid-cols');
+ const gridColsVal = $('#grid-cols-val');
+ const gridControls = $('#grid-controls');
+ const filterQ = $('#filter-q');
+ const pageInfo = $('#pageinfo');
+ const prevBtn = $('#prev');
+ const nextBtn = $('#next');
+
+ const PAGE_BY_VIEW = { grid: 100, list: 200, map: 500 };
+ function pageSize() { return PAGE_BY_VIEW[state.view] || 100; }
+ let state = {
+ entity: localStorage.getItem('pd.entity') || 'professionals',
+ view: localStorage.getItem('pd.view') || 'grid',
+ cols: Number(localStorage.getItem('pd.cols') || 5),
+ category: localStorage.getItem('pd.category') || '',
+ prospect: localStorage.getItem('pd.prospect') || '',
+ sort: localStorage.getItem('pd.sort') || 'score',
+ hideChains: localStorage.getItem('pd.hideChains') !== '0',
+ offset: 0,
+ q: '',
+ rows: [],
+ // Client-side column sort (applies to currently-loaded rows in list view).
+ listSortBy: localStorage.getItem('pd.listSortBy') || '',
+ listSortDir: localStorage.getItem('pd.listSortDir') || 'asc',
+ };
+
+ // Column definitions for renderList - key is the row property to sort on,
+ // label is the header text, cell(r) produces the td contents.
+ const COLS = {
+ professionals: [
+ { key:'full_name', label:'Name', cell: r => escapeHtml(r.full_name) },
+ { key:'title', label:'Title', cell: r => escapeHtml(r.title) },
+ { key:'primary_specialty', label:'Specialty', cell: r => escapeHtml(r.primary_specialty) },
+ { key:'license_number', label:'License #', cell: r => escapeHtml(r.license_number) },
+ { key:'license_status', label:'License Status', cell: r => escapeHtml(r.license_status) },
+ { key:'npi_number', label:'NPI', cell: r => escapeHtml(r.npi_number) },
+ { key:'email', label:'Email', cell: r => r.email
+ ? '<a class="email" href="mailto:' + escapeHtml(r.email) + '">' + escapeHtml(r.email) + '</a>'
+ : '<span style="color:#444">—</span>' },
+ { key:'medical_school', label:'School', cell: r => escapeHtml(r.medical_school) },
+ { key:'source_confidence_score', label:'Conf', cell: r => escapeHtml(r.source_confidence_score) },
+ ],
+ organizations: [
+ { key:'lead_score', label:'Score', cell: r => {
+ const sc = r.lead_score || 0;
+ const c = sc >= 80 ? '#10b981' : sc >= 50 ? '#facc15' : '#737373';
+ return '<span style="color:' + c + ';font-weight:600">' + sc + '</span>';
+ }},
+ { key:'name', label:'Name', cell: r => escapeHtml(r.name) + (r.is_chain ? ' <span style="color:#737373;font-size:11px">chain</span>' : '') },
+ { key:'type', label:'Type', cell: r => escapeHtml(r.type) },
+ { key:'has_website', label:'Prospect', cell: r => r.has_website
+ ? '<span style="color:#a1a1b5">has site</span>'
+ : '<span style="color:#facc15;font-weight:600">★ needs site</span>' },
+ { key:'suggested_domain', label:'Suggested .com', cell: r => r.domain_available
+ ? '<span style="color:#10b981;font-weight:600">✓ ' + escapeHtml(r.suggested_domain) + '</span>'
+ : (r.suggested_domain
+ ? '<span style="color:#737373">' + escapeHtml(r.suggested_domain) + ' taken</span>'
+ : '<span style="color:#444">—</span>') },
+ { key:'address', label:'Address', cell: r => escapeHtml(r.address) },
+ { key:'city', label:'City', cell: r => escapeHtml(r.city) },
+ { key:'zip', label:'ZIP', cell: r => escapeHtml(r.zip) },
+ { key:'phone', label:'Phone', cell: r => escapeHtml(r.phone) },
+ { key:'email', label:'Email', cell: r => r.email
+ ? '<a class="email" href="mailto:' + escapeHtml(r.email) + '">' + escapeHtml(r.email) + '</a>'
+ : '<span style="color:#444">—</span>' },
+ { key:'website', label:'Website', cell: r => r.website
+ ? (() => { const u = safeUrl(r.website); return u ? '<a href="' + escapeHtml(u) + '" target="_blank" rel="noopener">' + escapeHtml(u.replace(new RegExp('^https?://'),'').slice(0,40)) + '</a>' : ''; })()
+ : '' },
+ { key:'contacted_at', label:'Contacted', cell: r => r.contacted_at
+ ? '<span style="color:#10b981">✓ ' + new Date(r.contacted_at).toLocaleDateString() + '</span>'
+ : '' },
+ ],
+ };
+
+ // Generic comparator that handles strings, numbers, booleans, dates, nulls.
+ function cmpVal(a, b) {
+ const an = a == null || a === '';
+ const bn = b == null || b === '';
+ if (an && bn) return 0;
+ if (an) return 1; // nulls last
+ if (bn) return -1;
+ if (typeof a === 'number' && typeof b === 'number') return a - b;
+ if (typeof a === 'boolean' && typeof b === 'boolean') return (a===b)?0:(a?1:-1);
+ // date-shaped strings sort lexicographically already, which is correct
+ return String(a).localeCompare(String(b), undefined, { numeric:true, sensitivity:'base' });
+ }
+
+ function applyListSort(rows, key, dir) {
+ if (!key) return rows;
+ const sign = dir === 'desc' ? -1 : 1;
+ const sorted = rows.slice().sort((a, b) => sign * cmpVal(a[key], b[key]));
+ return sorted;
+ }
+
+ function buildOrgQS() {
+ const p = new URLSearchParams();
+ if (state.category) p.set('category', state.category);
+ if (state.prospect === 'needs') { p.set('has_website', 'false'); }
+ else if (state.prospect === 'has') { p.set('has_website', 'true'); }
+ else if (state.prospect === 'phone') { p.set('has_phone', 'true'); }
+ else if (state.prospect === 'phone_needs') { p.set('has_website','false'); p.set('has_phone','true'); }
+ else if (state.prospect === 'domain_avail') { p.set('has_website','false'); p.set('domain_available','true'); }
+ else if (state.prospect === 'starred') { p.set('starred','true'); }
+ else if (state.prospect === 'uncontacted') { p.set('contacted','false'); p.set('has_website','false'); }
+ else if (state.prospect === 'warmest') { p.set('min_score','80'); p.set('has_website','false'); }
+ else if (state.prospect === 'has_email') { p.set('has_email','true'); }
+ else if (state.prospect === 'outreach') { p.set('has_email','true'); p.set('contacted','false'); p.set('sort','score'); }
+ if (state.sort) p.set('sort', state.sort);
+ if (state.hideChains) p.set('is_chain', 'false');
+ return p;
+ }
+
+ function applyView() {
+ // Map view requires lat/lng — only orgs have those. If user is on
+ // professionals + map, transparently fall back to grid before rendering.
+ if (state.entity === 'professionals' && state.view === 'map') {
+ state.view = 'grid';
+ localStorage.setItem('pd.view', 'grid');
+ }
+ results.className = state.view;
+ document.documentElement.style.setProperty('--cols', String(state.cols));
+ gridColsVal.textContent = state.cols;
+ gridControls.style.display = state.view === 'grid' ? '' : 'none';
+ document.querySelectorAll('#view-toggle button').forEach(b => b.classList.toggle('active', b.dataset.view === state.view));
+ document.querySelectorAll('#entity-toggle button').forEach(b => b.classList.toggle('active', b.dataset.entity === state.entity));
+ // Hide Map button when no lat/lng exists for this entity (professionals).
+ const mapBtn = document.querySelector('#view-toggle button[data-view="map"]');
+ if (mapBtn) mapBtn.style.display = state.entity === 'professionals' ? 'none' : '';
+ const catEl = document.getElementById('category-select');
+ const prosEl = document.getElementById('prospect-select');
+ const sortEl = document.getElementById('sort-select');
+ const expEl = document.getElementById('export-link');
+ const isOrgs = state.entity === 'organizations';
+ catEl.style.display = isOrgs ? '' : 'none';
+ prosEl.style.display = isOrgs ? '' : 'none';
+ sortEl.style.display = isOrgs ? '' : 'none';
+ expEl.style.display = isOrgs ? '' : 'none';
+ catEl.value = state.category;
+ prosEl.value = state.prospect;
+ sortEl.value = state.sort;
+ }
+
+ // Race-safety: a stale fetch must not overwrite a newer one's rows.
+ let _loadReqId = 0;
+ let _loadAborter = null;
+ async function load() {
+ const myId = ++_loadReqId;
+ if (_loadAborter) { try { _loadAborter.abort(); } catch (_) {} }
+ _loadAborter = new AbortController();
+ const sig = _loadAborter.signal;
+
+ const lim = pageSize();
+ const params = new URLSearchParams({ limit: lim, offset: state.offset });
+ if (state.entity === 'organizations') {
+ for (const [k, v] of buildOrgQS()) params.set(k, v);
+ const exp = document.getElementById('export-link');
+ if (exp) {
+ const expQS = buildOrgQS();
+ expQS.set('limit', '5000');
+ exp.href = '/outreach.csv?' + expQS.toString();
+ }
+ }
+ let r;
+ try {
+ r = await fetch('/' + state.entity + '?' + params, { signal: sig }).then(r => r.json());
+ } catch (e) {
+ if (e.name === 'AbortError') return; // a newer request superseded us — drop silently
+ throw e;
+ }
+ if (myId !== _loadReqId) return; // belt-and-suspenders: discard if a newer request started
+
+ state.rows = (r.rows || []).filter(row => {
+ if (!state.q) return true;
+ const hay = state.entity === 'professionals'
+ ? (row.full_name || '') + ' ' + (row.primary_specialty || '')
+ : (row.name || '') + ' ' + (row.city || '');
+ return hay.toLowerCase().includes(state.q.toLowerCase());
+ });
+ pageInfo.textContent = (state.offset + 1) + '–' + (state.offset + state.rows.length);
+ prevBtn.disabled = state.offset === 0;
+ nextBtn.disabled = state.rows.length < lim;
+ render();
+ }
+
+ function escapeHtml(s) {
+ return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
+ }
+ // Allowlist http(s) only — blocks javascript:/data:/vbscript: in any user-supplied href.
+ function safeUrl(u) {
+ const s = String(u == null ? '' : u).trim();
+ if (!s || !/^https?:\\/\\//i.test(s)) return '';
+ return s;
+ }
+
+ function renderGrid() {
+ if (!state.rows.length) return '<div class="empty">No matches</div>';
+ return state.rows.map(r => {
+ if (state.entity === 'professionals') {
+ return '<div class="item">'
+ + '<div class="name">' + escapeHtml(r.full_name) + (r.title ? ' <span class="badge">' + escapeHtml(r.title) + '</span>' : '') + '</div>'
+ + '<div class="meta">'
+ + (r.primary_specialty ? escapeHtml(r.primary_specialty) + '<br>' : '')
+ + (r.license_number ? 'Lic ' + escapeHtml(r.license_number) + ' · ' : '')
+ + (r.npi_number ? 'NPI ' + escapeHtml(r.npi_number) : '')
+ + '</div></div>';
+ }
+ const prospectBadge = r.has_website
+ ? '<span class="badge" style="background:#1c2237;color:#a1a1b5">has site</span>'
+ : '<span class="badge" style="background:#1c2237;color:#facc15">★ needs site</span>';
+ const phoneBadge = r.has_phone ? '<span class="badge" style="background:#1c2237;color:#10b981">📞</span>' : '';
+ const domainBadge = r.domain_available
+ ? '<span class="badge" style="background:#10b981;color:#04130c;font-weight:600">✓ ' + escapeHtml(r.suggested_domain) + ' available</span>'
+ : (r.suggested_domain && r.domain_available === false
+ ? '<span class="badge" style="background:#1c2237;color:#737373">' + escapeHtml(r.suggested_domain) + ' taken</span>'
+ : '');
+ const phoneLink = r.phone
+ ? '<a href="tel:' + escapeHtml(r.phone.replace(/[^+0-9]/g,'')) + '" style="color:var(--accent)">📞 ' + escapeHtml(r.phone) + '</a>'
+ : '';
+ const emailLink = r.email
+ ? '<a href="mailto:' + escapeHtml(r.email) + '" style="color:var(--accent)">✉ ' + escapeHtml(r.email) + '</a>'
+ : '';
+ const mapLink = (r.lat && r.lng)
+ ? '<a href="https://www.google.com/maps/search/?api=1&query=' + r.lat + ',' + r.lng + '" target="_blank" rel="noopener" style="color:var(--accent)">🗺 map</a>'
+ : '';
+ const regLink = r.domain_available
+ ? '<a href="https://www.godaddy.com/domainsearch/find?domainToCheck=' + escapeHtml(r.suggested_domain) + '" target="_blank" rel="noopener" style="color:var(--accent)">register</a>'
+ : '';
+ const rowJSON = encodeURIComponent(JSON.stringify({
+ id: r.id, name: r.name, type: r.type, phone: r.phone || '', address: r.address || '',
+ city: r.city || '', zip: r.zip || '',
+ suggested_domain: r.suggested_domain || '', domain_available: !!r.domain_available,
+ category: state.category || ''
+ }));
+ const starBtn = '<button data-star="' + r.id + '" data-cur="' + (r.starred ? '1':'0') + '" style="background:none;border:0;cursor:pointer;font-size:14px;padding:0;color:' + (r.starred?'#facc15':'#444') + '" title="Star prospect">' + (r.starred?'⭐':'☆') + '</button>';
+ const contactBtn = '<button data-touch="' + r.id + '" data-flag="' + (r.contacted_at ? 'uncontact':'contact') + '" style="background:none;border:0;cursor:pointer;font-size:12px;padding:0;color:' + (r.contacted_at?'#10b981':'var(--muted)') + '" title="Mark contacted">' + (r.contacted_at ? '✓ contacted' : 'mark contacted') + '</button>';
+ const pitchBtn = '<button data-pitch="' + rowJSON + '" style="background:none;border:0;cursor:pointer;font-size:12px;padding:0;color:var(--accent)" title="Open pitch helper">✉ pitch</button>';
+ const copyBtn = '<button data-copy="' + rowJSON + '" style="background:none;border:0;color:var(--muted);cursor:pointer;font-size:12px;padding:0">📋 copy</button>';
+ const cardBg = r.starred ? '#1a1f33' : 'var(--panel)';
+ const score = r.lead_score || 0;
+ const scoreColor = score >= 80 ? '#10b981' : score >= 50 ? '#facc15' : '#737373';
+ const scoreBadge = '<span class="badge" style="background:' + scoreColor + ';color:#04130c;font-weight:600">' + score + '</span>';
+ return '<div class="item" style="background:' + cardBg + '">'
+ + '<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">'
+ + '<div class="name">' + escapeHtml(r.name) + '</div>'
+ + '<div style="display:flex;gap:6px;align-items:center">' + scoreBadge + ' ' + starBtn + '</div>'
+ + '</div>'
+ + '<div style="margin:2px 0 6px">'
+ + (r.type ? '<span class="badge">' + escapeHtml(r.type) + '</span> ' : '')
+ + prospectBadge + ' ' + phoneBadge
+ + (r.is_chain ? ' <span class="badge" style="background:#1c2237;color:#737373">chain</span>' : '')
+ + (r.contacted_at ? ' <span class="badge" style="background:#10b981;color:#04130c">contacted</span>' : '')
+ + '</div>'
+ + (domainBadge ? '<div style="margin-bottom:6px">' + domainBadge + (regLink ? ' · ' + regLink : '') + '</div>' : '')
+ + '<div class="meta">'
+ + (r.address ? escapeHtml(r.address) + '<br>' : '')
+ + escapeHtml([r.city, r.zip].filter(Boolean).join(', '))
+ + (phoneLink ? '<br>' + phoneLink : '')
+ + (emailLink ? '<br>' + emailLink : '')
+ + (() => { const u = safeUrl(r.website); return u ? '<br><a href="' + escapeHtml(u) + '" target="_blank" rel="noopener">' + escapeHtml(u.replace(new RegExp('^https?://'),'').slice(0,40)) + '</a>' : ''; })()
+ + '</div>'
+ + '<div style="display:flex;gap:10px;margin-top:8px;font-size:12px;flex-wrap:wrap">'
+ + (mapLink ? mapLink + ' · ' : '') + pitchBtn + ' · ' + contactBtn + ' · ' + copyBtn
+ + '</div>'
+ + '</div>';
+ }).join('');
+ }
+
+ function renderList() {
+ if (!state.rows.length) return '<div class="empty">No matches</div>';
+ const cols = COLS[state.entity] || [];
+ const sortKey = state.listSortBy;
+ const sortDir = state.listSortDir;
+
+ // Click handler is delegated below in setupListSortDelegate(); here we
+ // emit data-sort attributes so the delegate can pick them up.
+ // aria-sort + role=button + tabindex make the headers screen-reader-friendly
+ // and keyboard-activatable (Enter/Space). Keyboard handler is added below.
+ const head = '<tr>' + cols.map(c => {
+ const isActive = sortKey === c.key;
+ const ariaSort = isActive ? (sortDir === 'desc' ? 'descending' : 'ascending') : 'none';
+ const arrow = isActive
+ ? (sortDir === 'desc' ? ' <span aria-hidden="true" style="color:var(--accent)">▼</span>' : ' <span aria-hidden="true" style="color:var(--accent)">▲</span>')
+ : ' <span aria-hidden="true" style="color:#3a3a52;font-size:10px">↕</span>';
+ return '<th scope="col" role="columnheader" aria-sort="' + ariaSort + '" data-sort="' + c.key + '" tabindex="0" style="cursor:pointer;user-select:none">' + escapeHtml(c.label) + arrow + '</th>';
+ }).join('') + '</tr>';
+
+ const sortedRows = applyListSort(state.rows, sortKey, sortDir);
+ const body = sortedRows.map(r => '<tr>' + cols.map(c => '<td>' + (c.cell(r) || '') + '</td>').join('') + '</tr>').join('');
+ return '<table>' + head + body + '</table>';
+ }
+
+ let map = null;
+ let mapLayer = null;
+ async function renderMap() {
+ results.innerHTML = '<div id="leaflet"></div>';
+ const el = document.getElementById('leaflet');
+ if (!window.L) {
+ el.innerHTML = '<div class="map-empty">Leaflet failed to load — check your connection (the map JS is fetched from unpkg.com).</div>';
+ return;
+ }
+ map = L.map(el, { preferCanvas: true }).setView([34.05, -118.25], 10);
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ attribution: '© OpenStreetMap', maxZoom: 18,
+ }).addTo(map);
+ // Use clustering when the plugin is present, otherwise fall back to a flat
+ // layer group (still works, just slower at high pin counts).
+ mapLayer = (window.L.markerClusterGroup
+ ? L.markerClusterGroup({ chunkedLoading: true, spiderfyOnMaxZoom: true, showCoverageOnHover: false, maxClusterRadius: 60 })
+ : L.layerGroup());
+ map.addLayer(mapLayer);
+
+ // Loading badge.
+ const loading = document.createElement('div');
+ loading.className = 'map-loading';
+ loading.textContent = 'Loading pins…';
+ el.appendChild(loading);
+
+ // Fetch all matching pins (not just the current page).
+ const params = new URLSearchParams();
+ if (state.entity === 'organizations') {
+ for (const [k, v] of buildOrgQS()) params.set(k, v);
+ }
+ let pins = [];
+ try {
+ const r = await fetch('/map.json?' + params).then(r => r.json());
+ pins = r.rows || [];
+ } catch (_) { /* ignore — fall through to empty state */ }
+ loading.remove();
+ addPinsFromMapJson(pins);
+ }
+ function addPinsFromMapJson(rows) {
+ if (!mapLayer) return;
+ mapLayer.clearLayers();
+ const prevEmpty = document.querySelector('#leaflet .map-empty');
+ if (prevEmpty) prevEmpty.remove();
+
+ const bounds = [];
+ for (const r of rows) {
+ if (!r.lat || !r.lng) continue;
+ const needs = !r.has_website;
+ const hasEmail = !!r.has_email;
+ const color = needs ? '#facc15' : (hasEmail ? '#10b981' : '#737373');
+ const dot = L.circleMarker([r.lat, r.lng], {
+ radius: needs ? 6 : 4, color, weight: 1.5, fillColor: color,
+ fillOpacity: needs ? 0.9 : (hasEmail ? 0.85 : 0.5),
+ });
+ const safe = (s) => String(s == null ? '' : s).replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c]));
+ const popup = '<div class="pname">' + safe(r.name) + '</div>'
+ + '<div>' + safe(r.type) + ' · '
+ + (needs ? '<span class="needs">★ needs site</span>' : '<span class="has">has site</span>')
+ + (hasEmail ? ' · <span class="has">✉ email</span>' : '')
+ + (r.contacted ? ' · <span style="color:#10b981">✓ contacted</span>' : '')
+ + '</div>'
+ + '<div style="margin-top:6px"><a href="/preview/' + r.id + '/all" target="_blank">↗ see 5 design directions</a></div>';
+ dot.bindPopup(popup);
+ mapLayer.addLayer ? mapLayer.addLayer(dot) : dot.addTo(mapLayer);
+ bounds.push([r.lat, r.lng]);
+ }
+ if (bounds.length) {
+ try { map.fitBounds(bounds, { padding: [40, 40] }); } catch (_) {}
+ } else {
+ const elx = document.getElementById('leaflet');
+ if (elx) {
+ const msg = document.createElement('div');
+ msg.className = 'map-empty';
+ msg.textContent = state.entity === 'professionals'
+ ? 'Professionals have no map location — switch to Organizations to see pins.'
+ : 'No map points match this filter — relax filters or wait for more geocoding.';
+ elx.appendChild(msg);
+ }
+ }
+ }
+ // (legacy addPins(state.rows) was removed — renderMap now uses
+ // addPinsFromMapJson(rows-from-/map.json) for the full clustered set.)
+
+ async function loadZipHeat() {
+ const heatEl = document.getElementById('zip-heat');
+ if (state.view !== 'map') { heatEl.innerHTML = ''; return; }
+ const params = buildOrgQS();
+ try {
+ const r = await fetch('/stats/by-zip?' + params).then(r => r.json());
+ const top = (r.rows || []).slice(0, 30);
+ if (!top.length) { heatEl.innerHTML = ''; return; }
+ heatEl.innerHTML = '<h3>Top ZIPs by prospects (no website) — click to filter</h3>'
+ + top.map(z => '<span class="ziprow" data-zip="' + escapeHtml(z.zip) + '"><b>' + escapeHtml(z.prospects) + '</b> needs · <span>' + escapeHtml(z.total) + ' total</span> · ' + escapeHtml(z.zip) + '</span>').join('');
+ heatEl.querySelectorAll('.ziprow').forEach(el => {
+ el.addEventListener('click', () => {
+ // Center the map on this ZIP's first marker (cheap: refilter on the loaded set)
+ const zip = el.dataset.zip;
+ const targets = state.rows.filter(r => r.zip === zip && r.lat && r.lng);
+ if (map && targets.length) map.setView([targets[0].lat, targets[0].lng], 14);
+ });
+ });
+ } catch (_) { /* ignore */ }
+ }
+
+ // Keyboard support for sortable headers — Enter/Space activates sort
+ // (mirrors click handler). Required for a11y because we use <th> not <button>.
+ function activateSort(th) {
+ const key = th.getAttribute('data-sort');
+ if (state.listSortBy === key) {
+ state.listSortDir = state.listSortDir === 'asc' ? 'desc' : 'asc';
+ } else {
+ state.listSortBy = key;
+ state.listSortDir = 'asc';
+ }
+ localStorage.setItem('pd.listSortBy', state.listSortBy);
+ localStorage.setItem('pd.listSortDir', state.listSortDir);
+ render();
+ }
+ results.addEventListener('keydown', (e) => {
+ const th = e.target.closest && e.target.closest('th[data-sort]');
+ if (!th) return;
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ activateSort(th);
+ }
+ });
+
+ // Delegated handlers for prospect-card actions.
+ results.addEventListener('click', async (e) => {
+ // Column-header sort in list view.
+ const sortTh = e.target.closest('th[data-sort]');
+ if (sortTh) {
+ activateSort(sortTh);
+ return;
+ }
+ // Copy contact card to clipboard.
+ const copyBtn = e.target.closest('[data-copy]');
+ if (copyBtn) {
+ try {
+ const data = JSON.parse(decodeURIComponent(copyBtn.getAttribute('data-copy') || '{}'));
+ const text = [
+ data.name,
+ data.phone,
+ [data.address, data.city, data.zip].filter(Boolean).join(' · '),
+ data.suggested_domain ? 'Suggested: ' + data.suggested_domain : ''
+ ].filter(Boolean).join('\\n');
+ await navigator.clipboard.writeText(text);
+ const orig = copyBtn.textContent;
+ copyBtn.textContent = '✓ copied'; copyBtn.style.color = '#10b981';
+ setTimeout(() => { copyBtn.textContent = orig; copyBtn.style.color = ''; }, 1200);
+ } catch (_) {}
+ return;
+ }
+ // Star toggle.
+ const starBtn = e.target.closest('[data-star]');
+ if (starBtn) {
+ const id = starBtn.getAttribute('data-star');
+ const cur = starBtn.getAttribute('data-cur') === '1';
+ try {
+ const r = await fetch('/organizations/' + id + '/touch', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ starred: !cur })
+ }).then(r => r.json());
+ starBtn.setAttribute('data-cur', r.starred ? '1':'0');
+ starBtn.textContent = r.starred ? '⭐' : '☆';
+ starBtn.style.color = r.starred ? '#facc15' : '#444';
+ starBtn.closest('.item').style.background = r.starred ? '#1a1f33' : 'var(--panel)';
+ } catch (_) {}
+ return;
+ }
+ // Mark contacted toggle.
+ const tBtn = e.target.closest('[data-touch]');
+ if (tBtn) {
+ const id = tBtn.getAttribute('data-touch');
+ const flag = tBtn.getAttribute('data-flag');
+ const want = flag === 'contact';
+ try {
+ const r = await fetch('/organizations/' + id + '/touch', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ contacted: want })
+ }).then(r => r.json());
+ tBtn.textContent = r.contacted_at ? '✓ contacted' : 'mark contacted';
+ tBtn.style.color = r.contacted_at ? '#10b981' : 'var(--muted)';
+ tBtn.setAttribute('data-flag', r.contacted_at ? 'uncontact' : 'contact');
+ } catch (_) {}
+ return;
+ }
+ // Pitch helper modal.
+ const pBtn = e.target.closest('[data-pitch]');
+ if (pBtn) {
+ try {
+ const data = JSON.parse(decodeURIComponent(pBtn.getAttribute('data-pitch') || '{}'));
+ openPitchModal(data);
+ } catch (_) {}
+ return;
+ }
+ });
+
+ function openPitchModal(d) {
+ const back = document.createElement('div');
+ back.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:1000;display:flex;align-items:center;justify-content:center;padding:24px';
+ const card = document.createElement('div');
+ card.style.cssText = 'background:var(--panel);border:1px solid var(--border);border-radius:14px;padding:22px;max-width:640px;width:100%;color:var(--fg);box-shadow:0 20px 60px rgba(0,0,0,0.6)';
+
+ // Guard against XSS in subject/body — d.name and d.suggested_domain come
+ // from DB rows and could contain </textarea>, quotes, or HTML chars.
+ function safeWebUrl(u) {
+ const s = String(u || '').trim();
+ if (!/^https?:\/\//i.test(s)) return '';
+ return s;
+ }
+ const safeName = String(d.name || '').replace(/[<>"]/g, '');
+ const safeDomain = String(d.suggested_domain || '').replace(/[<>"]/g, '');
+ const subject = 'A modern site for ' + safeName + (safeDomain && d.domain_available ? ' (' + safeDomain + ')' : '');
+ const themeWord = ({
+ cosmetology: 'salon-quality, beauty-forward',
+ chiropractic: 'patient-friendly, appointment-driven',
+ chinese_medicine: 'calm, modality-respectful',
+ dental: 'trust-building, appointment-driven',
+ optometry: 'modern eyewear-forward',
+ fitness: 'energetic, class-schedule-driven',
+ tattoo: 'portfolio-led, gallery-first',
+ pets: 'warm, owner-friendly'
+ })[d.category] || 'beautifully themed';
+
+ const domainLine = safeDomain && d.domain_available
+ ? 'I checked — ' + safeDomain + ' is still available.'
+ : (safeDomain ? '(Domain idea: ' + safeDomain + ')' : '');
+
+ // Public host for the preview link in the email body. When undefined we
+ // fall back to the loopback host (only useful while Steve drives the
+ // admin himself; switch via window.PD_PREVIEW_BASE before going live).
+ const previewBase = (window.PD_PREVIEW_BASE || (window.location.origin)).replace(/\\/$/, '');
+ const previewUrl = previewBase + '/preview/' + d.id + '/all';
+
+ const body =
+ 'Hi — I drafted five front-page concepts for ' + safeName + '.\\n\\n' +
+ 'You don\\'t have to chat. Just go take a look — five different design directions, side by side:\\n' +
+ previewUrl + '\\n\\n' +
+ 'Everything on the page is live data — your real address, phone, and (if applicable) the providers you have on file. The looks are mine to revise. Pick the one that fits, reply with which one (A · modern, B · warm, C · premium, D · wellness, or E · editorial), and I\\'ll set up the real domain and put it live.\\n\\n' +
+ 'If none of them work, no harm done — close the tab.\\n\\n' +
+ '— Steve\\n' +
+ 'steve@designerwallcoverings.com';
+
+ card.style.maxWidth = '780px';
+ card.innerHTML =
+ '<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:14px">' +
+ '<div><div style="font-size:18px;font-weight:600">' + escapeHtml(d.name) + '</div>' +
+ '<div style="color:var(--muted);font-size:12px;margin-top:2px">' + escapeHtml(d.type || '') + (d.phone ? ' · ' + escapeHtml(d.phone) : '') + '</div></div>' +
+ '<button id="pitch-close" style="background:none;border:0;color:var(--muted);font-size:20px;cursor:pointer">×</button>' +
+ '</div>' +
+ '<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:14px">' +
+ '<div>' +
+ '<div style="font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);margin-bottom:4px">Their site today</div>' +
+ ((() => { const u = safeUrl(d.website); return u
+ ? '<iframe sandbox src="' + escapeHtml(u) + '" style="width:100%;aspect-ratio:5/4;border:1px solid var(--border);border-radius:6px;background:#fff" loading="lazy"></iframe>'
+ : '<div style="aspect-ratio:5/4;border:1px dashed var(--border);border-radius:6px;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:12px;text-align:center;padding:12px">no current website</div>'; })()) +
+ '</div>' +
+ '<div>' +
+ '<div style="font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);margin-bottom:4px">Editorial revamp</div>' +
+ '<iframe sandbox="allow-same-origin" src="/preview/' + d.id + '" style="width:100%;aspect-ratio:5/4;border:1px solid var(--border);border-radius:6px;background:#fff" loading="lazy"></iframe>' +
+ '</div>' +
+ '<div>' +
+ '<div style="font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--accent);margin-bottom:4px">★ Bespoke mockup</div>' +
+ '<a href="/mockups/' + d.id + '.html" target="_blank" rel="noopener" style="display:block">' +
+ '<img src="/mockups/' + d.id + '.png" style="width:100%;aspect-ratio:5/4;border:1px solid var(--accent);border-radius:6px;background:#fff;object-fit:cover;object-position:top" loading="lazy" onerror="this.parentElement.innerHTML="<div style=\\"aspect-ratio:5/4;border:1px dashed var(--border);border-radius:6px;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:11px;text-align:center;padding:12px\\">no mockup yet</div>"">' +
+ '</a>' +
+ '</div>' +
+ '</div>' +
+ '<div style="display:flex;gap:10px;align-items:center;margin-bottom:12px;font-size:12px;flex-wrap:wrap">' +
+ '<a href="/preview/' + d.id + '/all" target="_blank" rel="noopener" style="color:var(--accent);font-weight:600">★ see 5 design directions</a>' +
+ '<a href="/mockups/' + d.id + '.html" target="_blank" rel="noopener" style="color:var(--muted)">bespoke mockup</a>' +
+ '<details style="display:inline-block;margin:0;font-size:12px"><summary style="color:var(--muted);cursor:pointer;list-style:none;display:inline">individual variants ▾</summary>' +
+ '<a href="/preview/' + d.id + '/a" target="_blank" rel="noopener" style="color:var(--muted);margin-left:10px">A · modern</a>' +
+ '<a href="/preview/' + d.id + '/b" target="_blank" rel="noopener" style="color:var(--muted);margin-left:10px">B · warm</a>' +
+ '<a href="/preview/' + d.id + '/c" target="_blank" rel="noopener" style="color:var(--muted);margin-left:10px">C · premium</a>' +
+ '<a href="/preview/' + d.id + '/d" target="_blank" rel="noopener" style="color:var(--muted);margin-left:10px">D · wellness</a>' +
+ '<a href="/preview/' + d.id + '/e" target="_blank" rel="noopener" style="color:var(--muted);margin-left:10px">E · brutalist</a>' +
+ '</details>' +
+ ((() => { const u = safeUrl(d.website); return u ? '<a href="' + escapeHtml(u) + '" target="_blank" rel="noopener" style="color:var(--muted)">their current site</a>' : ''; })()) +
+ '</div>' +
+ '<label style="font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted)">To</label>' +
+ '<select id="pitch-to" style="width:100%;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:8px 10px;margin:4px 0 12px;font:13px system-ui"><option value="">— loading recipients —</option></select>' +
+ '<label style="font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted)">Subject</label>' +
+ '<input id="pitch-subject" type="text" style="width:100%;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:8px 10px;margin:4px 0 12px" value="' + subject.replace(/"/g,'"') + '">' +
+ '<label style="font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted)">Body</label>' +
+ '<textarea id="pitch-body" style="width:100%;height:200px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:10px;margin:4px 0 14px;font:13px/1.5 system-ui">' + body + '</textarea>' +
+ '<div style="display:flex;gap:10px;flex-wrap:wrap">' +
+ '<button id="pitch-copy" style="background:var(--accent);color:#04130c;border:0;padding:8px 14px;border-radius:6px;font-weight:600;cursor:pointer">Copy email</button>' +
+ '<button id="pitch-mailto" style="background:var(--bg);color:var(--fg);border:1px solid var(--border);padding:8px 14px;border-radius:6px;cursor:pointer">Open in mail client</button>' +
+ '<button id="pitch-mark" style="background:var(--bg);color:var(--fg);border:1px solid var(--border);padding:8px 14px;border-radius:6px;cursor:pointer;margin-left:auto">Mark contacted ✓</button>' +
+ '</div>';
+ back.appendChild(card);
+ document.body.appendChild(card.parentElement === back ? back : back);
+
+ // Populate recipient list from /organizations/:id/emails
+ fetch('/organizations/' + d.id + '/emails')
+ .then(r => r.json())
+ .then(j => {
+ const sel = document.getElementById('pitch-to');
+ if (!sel) return;
+ if (!j.rows || j.rows.length === 0) {
+ sel.innerHTML = '<option value="">— no email known — paste manually below —</option>';
+ sel.style.color = '#facc15';
+ } else {
+ sel.innerHTML = j.rows.map((e, i) => '<option' + (i===0?' selected':'') + ' value="' + escapeHtml(e.email) + '">' + escapeHtml(e.email) + (e.email_type ? ' · ' + escapeHtml(e.email_type) : '') + '</option>').join('');
+ }
+ })
+ .catch(() => { const sel = document.getElementById('pitch-to'); if (sel) sel.innerHTML = '<option value="">— lookup failed —</option>'; });
+
+ document.getElementById('pitch-close').onclick = () => back.remove();
+ back.addEventListener('click', (ev) => { if (ev.target === back) back.remove(); });
+ document.getElementById('pitch-copy').onclick = async () => {
+ const to = document.getElementById('pitch-to').value;
+ const subj = document.getElementById('pitch-subject').value;
+ const text = document.getElementById('pitch-body').value;
+ const composed = (to ? 'To: ' + to + '\\n' : '') + 'Subject: ' + subj + '\\n\\n' + text;
+ await navigator.clipboard.writeText(composed);
+ const b = document.getElementById('pitch-copy');
+ b.textContent = '✓ copied'; setTimeout(() => b.textContent = 'Copy email', 1200);
+ };
+ document.getElementById('pitch-mailto').onclick = () => {
+ const to = document.getElementById('pitch-to').value;
+ const subj = document.getElementById('pitch-subject').value;
+ const txt = document.getElementById('pitch-body').value;
+ window.location.href = 'mailto:' + encodeURIComponent(to) + '?subject=' + encodeURIComponent(subj) + '&body=' + encodeURIComponent(txt);
+ };
+ document.getElementById('pitch-mark').onclick = async () => {
+ try {
+ await fetch('/organizations/' + d.id + '/touch', {
+ method: 'POST', headers: {'Content-Type':'application/json'},
+ body: JSON.stringify({ contacted: true })
+ });
+ back.remove();
+ load();
+ } catch (_) {}
+ };
+ }
+
+ function render() {
+ if (state.view === 'map') {
+ // Tear down any prior map instance.
+ if (map) { try { map.remove(); } catch(_){} map = null; mapLayer = null; }
+ renderMap();
+ loadZipHeat();
+ } else {
+ if (map) { try { map.remove(); } catch(_){} map = null; mapLayer = null; }
+ document.getElementById('zip-heat').innerHTML = '';
+ results.innerHTML = state.view === 'grid' ? renderGrid() : renderList();
+ }
+ }
+
+ // Events
+ document.querySelectorAll('#view-toggle button').forEach(b => {
+ b.addEventListener('click', () => {
+ const prev = state.view;
+ state.view = b.dataset.view;
+ localStorage.setItem('pd.view', state.view);
+ applyView();
+ // PAGE size changes between views; refetch when needed.
+ if (PAGE_BY_VIEW[prev] !== PAGE_BY_VIEW[state.view]) {
+ state.offset = 0;
+ load();
+ } else {
+ render();
+ }
+ });
+ });
+ document.querySelectorAll('#entity-toggle button').forEach(b => {
+ b.addEventListener('click', () => {
+ state.entity = b.dataset.entity;
+ localStorage.setItem('pd.entity', state.entity);
+ state.offset = 0;
+ applyView();
+ load();
+ });
+ });
+ gridColsEl.addEventListener('input', () => {
+ state.cols = Number(gridColsEl.value);
+ localStorage.setItem('pd.cols', state.cols);
+ document.documentElement.style.setProperty('--cols', String(state.cols));
+ gridColsVal.textContent = state.cols;
+ });
+ filterQ.addEventListener('input', () => { state.q = filterQ.value; render(); });
+ document.getElementById('category-select').addEventListener('change', (e) => {
+ state.category = e.target.value;
+ localStorage.setItem('pd.category', state.category);
+ state.offset = 0;
+ load();
+ });
+ document.getElementById('prospect-select').addEventListener('change', (e) => {
+ state.prospect = e.target.value;
+ localStorage.setItem('pd.prospect', state.prospect);
+ state.offset = 0;
+ load();
+ });
+ document.getElementById('sort-select').addEventListener('change', (e) => {
+ state.sort = e.target.value;
+ localStorage.setItem('pd.sort', state.sort);
+ state.offset = 0;
+ load();
+ });
+ document.getElementById('hide-chains').addEventListener('change', (e) => {
+ state.hideChains = e.target.checked;
+ localStorage.setItem('pd.hideChains', state.hideChains ? '1' : '0');
+ state.offset = 0;
+ load();
+ });
+ prevBtn.addEventListener('click', () => { state.offset = Math.max(0, state.offset - pageSize()); load(); });
+ nextBtn.addEventListener('click', () => { state.offset += pageSize(); load(); });
+
+ // Init
+ gridColsEl.value = state.cols;
+ applyView();
+ load();
+})();
+</script>
</body></html>`;
}
-app.listen(PORT, '127.0.0.1', () => {
- console.log(`[api] listening on http://127.0.0.1:${PORT}`);
+app.listen(PORT, '0.0.0.0', () => {
+ console.log(`[api] listening on http://0.0.0.0:${PORT} (loopback-admin gated)`);
});
diff --git a/agents/crawler-agent/crawlers/front-page.js b/agents/crawler-agent/crawlers/front-page.js
new file mode 100644
index 0000000..8ebb044
--- /dev/null
+++ b/agents/crawler-agent/crawlers/front-page.js
@@ -0,0 +1,236 @@
+/**
+ * Front-page HTML capture for organizations with a `website`.
+ *
+ * Drives ad-social-tracker Pass 1 (regex pixel detection) downstream via
+ * src/enrich/ad_signals.js. Internal sales-targeting only — admin-gated
+ * output. We pull only the public marketing front page (one URL per org),
+ * never deeper paths, never anything resembling patient/PHI surface.
+ *
+ * Behavior:
+ * - Selects orgs with website IS NOT NULL/empty AND no front_page_audits row
+ * in last 30 days.
+ * - Playwright headless chromium, 5s nav timeout, single browser, single
+ * context, max 5 parallel pages.
+ * - Saves page.content() to data/raw/org-<id>-<ts>.html.
+ * - INSERTs front_page_audits row per attempt (success OR failure — failures
+ * get raw_html_path=NULL, http_status=null/code).
+ * - Skips robots.txt-disallowed paths.
+ * - User-agent: DoctorDirectoryBot/1.0
+ * - HIPAA: only the website URL declared by the org. Never widen scope.
+ */
+const { chromium } = require('playwright');
+const fs = require('node:fs');
+const path = require('node:path');
+const { URL } = require('node:url');
+const robotsParser = require('robots-parser');
+const { pool, query } = require('../../shared/db');
+
+const UA = 'DoctorDirectoryBot/1.0 (+https://drvouch.local/bot)';
+const NAV_TIMEOUT_MS = 5000;
+const CONCURRENCY = parseInt(process.env.CRAWL_CONCURRENCY || '5', 10);
+const LIMIT = parseInt(process.env.CRAWL_LIMIT || '500', 10);
+const STALE_DAYS = 30;
+const RAW_DIR = path.resolve(__dirname, '../../../data/raw');
+const REPO_ROOT = path.resolve(__dirname, '../../..');
+
+fs.mkdirSync(RAW_DIR, { recursive: true });
+
+// In-memory robots.txt cache per origin
+const robotsCache = new Map();
+
+function normalizeUrl(raw) {
+ if (!raw) return null;
+ let s = String(raw).trim();
+ if (!s) return null;
+ if (!/^https?:\/\//i.test(s)) s = 'http://' + s;
+ try {
+ const u = new URL(s);
+ return u.toString();
+ } catch {
+ return null;
+ }
+}
+
+async function getRobots(origin) {
+ if (robotsCache.has(origin)) return robotsCache.get(origin);
+ const robotsUrl = origin + '/robots.txt';
+ let parser;
+ try {
+ const res = await fetch(robotsUrl, {
+ method: 'GET',
+ headers: { 'user-agent': UA },
+ signal: AbortSignal.timeout(4000),
+ });
+ const body = res.ok ? await res.text() : '';
+ parser = robotsParser(robotsUrl, body);
+ } catch {
+ // No robots → allowed by default (common for small sites)
+ parser = robotsParser(robotsUrl, '');
+ }
+ robotsCache.set(origin, parser);
+ return parser;
+}
+
+async function fetchOrgs(limit) {
+ const sql = `
+ SELECT o.id, o.name, o.website
+ FROM organizations o
+ WHERE o.website IS NOT NULL
+ AND o.website <> ''
+ AND o.opted_out = false
+ AND NOT EXISTS (
+ SELECT 1 FROM front_page_audits a
+ WHERE a.organization_id = o.id
+ AND a.audited_at > now() - INTERVAL '${STALE_DAYS} days'
+ )
+ ORDER BY o.id
+ LIMIT $1
+ `;
+ const r = await query(sql, [limit]);
+ return r.rows;
+}
+
+async function crawlOne(context, org) {
+ const url = normalizeUrl(org.website);
+ if (!url) {
+ await query(
+ `INSERT INTO front_page_audits (organization_id, url, raw_html_path, http_status, fetch_ms)
+ VALUES ($1, $2, NULL, NULL, NULL)`,
+ [org.id, org.website || '']
+ );
+ return { id: org.id, ok: false, reason: 'bad_url' };
+ }
+
+ let origin;
+ try {
+ origin = new URL(url).origin;
+ } catch {
+ return { id: org.id, ok: false, reason: 'bad_url' };
+ }
+
+ // robots.txt check
+ try {
+ const robots = await getRobots(origin);
+ if (!robots.isAllowed(url, 'DoctorDirectoryBot')) {
+ await query(
+ `INSERT INTO front_page_audits (organization_id, url, raw_html_path, http_status, fetch_ms)
+ VALUES ($1, $2, NULL, $3, NULL)`,
+ [org.id, url, 999] // 999 = robots-disallowed sentinel
+ );
+ return { id: org.id, ok: false, reason: 'robots_disallow' };
+ }
+ } catch {
+ // continue — robots fetch failure is not fatal
+ }
+
+ const t0 = Date.now();
+ const page = await context.newPage();
+ let httpStatus = null;
+ let rawPath = null;
+ let ok = false;
+ let reason = null;
+
+ try {
+ page.setDefaultNavigationTimeout(NAV_TIMEOUT_MS);
+ const resp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: NAV_TIMEOUT_MS });
+ httpStatus = resp ? resp.status() : null;
+ if (resp && resp.ok()) {
+ const html = await page.content();
+ const ts = Date.now();
+ const rel = path.join('data', 'raw', `org-${org.id}-${ts}.html`);
+ const abs = path.join(REPO_ROOT, rel);
+ fs.writeFileSync(abs, html, 'utf8');
+ rawPath = rel;
+ ok = true;
+ } else {
+ reason = `http_${httpStatus}`;
+ }
+ } catch (e) {
+ reason = (e && e.message) ? e.message.slice(0, 80) : 'nav_error';
+ } finally {
+ try { await page.close(); } catch {}
+ }
+
+ const fetchMs = Date.now() - t0;
+ await query(
+ `INSERT INTO front_page_audits (organization_id, url, raw_html_path, http_status, fetch_ms)
+ VALUES ($1, $2, $3, $4, $5)`,
+ [org.id, url, rawPath, httpStatus, fetchMs]
+ );
+ return { id: org.id, ok, reason, httpStatus, fetchMs };
+}
+
+async function runPool(items, worker, concurrency) {
+ let i = 0;
+ let active = 0;
+ let done = 0;
+ const results = [];
+ return new Promise((resolve) => {
+ const next = () => {
+ while (active < concurrency && i < items.length) {
+ const idx = i++;
+ active++;
+ Promise.resolve(worker(items[idx]))
+ .then((r) => { results[idx] = r; })
+ .catch((e) => { results[idx] = { ok: false, reason: e?.message || 'err' }; })
+ .finally(() => {
+ active--;
+ done++;
+ if (done % 25 === 0) {
+ console.log(`[front-page] progress ${done}/${items.length}`);
+ }
+ if (done === items.length) resolve(results);
+ else next();
+ });
+ }
+ };
+ if (items.length === 0) resolve(results);
+ else next();
+ });
+}
+
+async function main() {
+ const limit = LIMIT;
+ console.log(`[front-page] fetching up to ${limit} orgs (stale > ${STALE_DAYS}d)…`);
+ const orgs = await fetchOrgs(limit);
+ console.log(`[front-page] ${orgs.length} orgs to crawl, concurrency=${CONCURRENCY}`);
+ if (orgs.length === 0) {
+ await pool.end();
+ return;
+ }
+
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({
+ userAgent: UA,
+ javaScriptEnabled: true,
+ bypassCSP: true,
+ ignoreHTTPSErrors: true,
+ viewport: { width: 1280, height: 900 },
+ });
+ // Block heavy media — we only need the HTML/script tags for ad-pixel detection
+ await context.route('**/*', (route) => {
+ const t = route.request().resourceType();
+ if (t === 'image' || t === 'media' || t === 'font') return route.abort();
+ return route.continue();
+ });
+
+ const results = await runPool(orgs, (o) => crawlOne(context, o), CONCURRENCY);
+
+ await context.close();
+ await browser.close();
+
+ const ok = results.filter((r) => r && r.ok).length;
+ const robotsBlocked = results.filter((r) => r && r.reason === 'robots_disallow').length;
+ const errs = results.length - ok - robotsBlocked;
+ console.log(`[front-page] done · ok=${ok} robots_blocked=${robotsBlocked} errors=${errs}`);
+ await pool.end();
+}
+
+if (require.main === module) {
+ main().catch((e) => {
+ console.error('[front-page] fatal', e);
+ process.exit(1);
+ });
+}
+
+module.exports = { crawlOne, normalizeUrl };
diff --git a/agents/crawler-agent/server.js b/agents/crawler-agent/server.js
new file mode 100644
index 0000000..6d2505b
--- /dev/null
+++ b/agents/crawler-agent/server.js
@@ -0,0 +1,81 @@
+#!/usr/bin/env node
+/**
+ * Crawler agent — HTTP control plane for hospital site crawlers.
+ *
+ * GET /health — { ok, agent, crawlers }
+ * GET /crawlers — list of crawler module ids that exist on disk
+ * POST /run/:id — async-fire crawlers/<id>.js
+ */
+const fs = require('node:fs');
+const path = require('path');
+const { spawn } = require('node:child_process');
+require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
+const express = require('express');
+const { pool, query } = require('../shared/db');
+
+const PORT = Number(process.env.PORT_CRAWLER || process.env.PORT || 9871);
+const CRAWLERS_DIR = path.join(__dirname, 'crawlers');
+const app = express();
+app.use(express.json());
+
+// LAN/tailnet auth gate (skip /health for watchdog probes)
+const BASIC_AUTH = process.env.BASIC_AUTH || 'admin:DWSecure2024!';
+const AUTH_HEADER = 'Basic ' + Buffer.from(BASIC_AUTH).toString('base64');
+app.use((req, res, next) => {
+ if (req.path === '/health' || req.path === '/healthz') return next();
+ if (req.get('authorization') === AUTH_HEADER) return next();
+ res.set('WWW-Authenticate', 'Basic realm="mac2-pm2"');
+ res.status(401).send('auth required');
+});
+
+function listCrawlers() {
+ if (!fs.existsSync(CRAWLERS_DIR)) return [];
+ return fs.readdirSync(CRAWLERS_DIR)
+ .filter(f => f.endsWith('.js'))
+ .map(f => f.replace(/\.js$/, ''));
+}
+
+app.get('/health', async (_req, res) => {
+ try {
+ await query('SELECT 1', []);
+ res.json({ ok: true, agent: 'pd-crawler', crawlers: listCrawlers() });
+ } catch (e) {
+ res.status(500).json({ ok: false, error: e.message });
+ }
+});
+
+app.get('/crawlers', (_req, res) => {
+ res.json({ count: listCrawlers().length, crawlers: listCrawlers() });
+});
+
+// SECURITY: was accepting `req.body.env` and merging into the spawned child's
+// environment with no auth. An attacker reaching this loopback port (via any
+// proxy that forwards) could overwrite NODE_PATH / DATABASE_URL / STRIPE keys
+// in the child. Removed env passthrough and added shared-secret token check.
+// CRAWLER_RUN_TOKEN must be set in pm2 env; pass via Authorization: Bearer.
+const CRAWLER_RUN_TOKEN = process.env.CRAWLER_RUN_TOKEN || '';
+app.post('/run/:id', (req, res) => {
+ if (!CRAWLER_RUN_TOKEN) {
+ return res.status(503).json({ error: 'CRAWLER_RUN_TOKEN not configured on server' });
+ }
+ const auth = String(req.get('authorization') || '');
+ if (auth !== `Bearer ${CRAWLER_RUN_TOKEN}`) {
+ return res.status(401).json({ error: 'unauthorized' });
+ }
+ const id = String(req.params.id);
+ if (!listCrawlers().includes(id)) {
+ return res.status(404).json({ error: `unknown crawler '${id}'`, available: listCrawlers() });
+ }
+ const child = spawn('node', [path.join(CRAWLERS_DIR, `${id}.js`)], {
+ cwd: path.resolve(__dirname, '../..'),
+ env: process.env, // NO caller-supplied env merge
+ detached: true,
+ stdio: 'ignore',
+ });
+ child.unref();
+ res.json({ ok: true, started: id, pid: child.pid });
+});
+
+app.listen(PORT, '0.0.0.0', () => {
+ console.log(`[pd-crawler] listening on http://127.0.0.1:${PORT}`);
+});
diff --git a/agents/dedupe-agent/server.js b/agents/dedupe-agent/server.js
new file mode 100644
index 0000000..51ae86b
--- /dev/null
+++ b/agents/dedupe-agent/server.js
@@ -0,0 +1,61 @@
+#!/usr/bin/env node
+/**
+ * Dedupe agent — HTTP control plane for entity-resolution passes.
+ *
+ * GET /health — { ok, agent, candidates }
+ * POST /run/link-locations — runs scripts/link_locations_to_orgs.js
+ *
+ * Future: NPI-based dedupe, fuzzy name dedupe, organization clustering.
+ */
+const fs = require('node:fs');
+const path = require('path');
+const { spawn } = require('node:child_process');
+require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
+const express = require('express');
+const { pool, query } = require('../shared/db');
+
+const PORT = Number(process.env.PORT_DEDUPE || process.env.PORT || 9873);
+const ROOT = path.resolve(__dirname, '../..');
+const SCRIPT_LINK = path.join(ROOT, 'scripts/link_locations_to_orgs.js');
+
+const app = express();
+app.use(express.json());
+
+// LAN/tailnet auth gate (skip /health for watchdog probes)
+const BASIC_AUTH = process.env.BASIC_AUTH || 'admin:DWSecure2024!';
+const AUTH_HEADER = 'Basic ' + Buffer.from(BASIC_AUTH).toString('base64');
+app.use((req, res, next) => {
+ if (req.path === '/health' || req.path === '/healthz') return next();
+ if (req.get('authorization') === AUTH_HEADER) return next();
+ res.set('WWW-Authenticate', 'Basic realm="mac2-pm2"');
+ res.status(401).send('auth required');
+});
+
+app.get('/health', async (_req, res) => {
+ try {
+ const r = await query(`
+ SELECT
+ (SELECT COUNT(*) FROM professional_locations) AS prof_locations_total,
+ (SELECT COUNT(*) FROM professional_locations WHERE organization_id IS NOT NULL) AS linked,
+ (SELECT COUNT(*) FROM professional_locations WHERE organization_id IS NULL) AS unlinked
+ `, []);
+ res.json({ ok: true, agent: 'pd-dedupe', ...r.rows[0] });
+ } catch (e) {
+ res.status(500).json({ ok: false, error: e.message });
+ }
+});
+
+app.post('/run/link-locations', (_req, res) => {
+ if (!fs.existsSync(SCRIPT_LINK)) {
+ return res.status(500).json({ error: `missing script: ${SCRIPT_LINK}` });
+ }
+ const child = spawn('node', [SCRIPT_LINK], {
+ cwd: ROOT, env: process.env, detached: true, stdio: 'ignore',
+ });
+ child.unref();
+ res.json({ ok: true, started: 'link-locations', pid: child.pid });
+});
+
+app.listen(PORT, '0.0.0.0', () => {
+ console.log(`[pd-dedupe] listening on http://127.0.0.1:${PORT}`);
+});
diff --git a/agents/enrich-agent/server.js b/agents/enrich-agent/server.js
new file mode 100644
index 0000000..b2022b6
--- /dev/null
+++ b/agents/enrich-agent/server.js
@@ -0,0 +1,68 @@
+#!/usr/bin/env node
+/**
+ * Enrich agent — HTTP control plane for geocoding + future enrichers.
+ *
+ * GET /health — { ok, agent, geocode_pending, enrichers }
+ * POST /run/geocode — async-fire geocode.js with optional { limit }
+ */
+const fs = require('node:fs');
+const path = require('path');
+const { spawn } = require('node:child_process');
+require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
+const express = require('express');
+const { pool, query } = require('../shared/db');
+
+const PORT = Number(process.env.PORT_ENRICH || process.env.PORT || 9872);
+const ENRICHERS_DIR = path.join(__dirname);
+const app = express();
+app.use(express.json());
+
+// LAN/tailnet auth gate (skip /health for watchdog probes)
+const BASIC_AUTH = process.env.BASIC_AUTH || 'admin:DWSecure2024!';
+const AUTH_HEADER = 'Basic ' + Buffer.from(BASIC_AUTH).toString('base64');
+app.use((req, res, next) => {
+ if (req.path === '/health' || req.path === '/healthz') return next();
+ if (req.get('authorization') === AUTH_HEADER) return next();
+ res.set('WWW-Authenticate', 'Basic realm="mac2-pm2"');
+ res.status(401).send('auth required');
+});
+
+function listEnrichers() {
+ return fs.readdirSync(ENRICHERS_DIR)
+ .filter(f => f.endsWith('.js') && f !== 'server.js')
+ .map(f => f.replace(/\.js$/, ''));
+}
+
+app.get('/health', async (_req, res) => {
+ try {
+ const r = await query(`
+ SELECT
+ (SELECT COUNT(*) FROM organizations WHERE lat IS NULL AND address IS NOT NULL) AS geocode_pending,
+ (SELECT COUNT(*) FROM organizations WHERE lat IS NOT NULL) AS geocoded
+ `, []);
+ res.json({ ok: true, agent: 'pd-enrich', enrichers: listEnrichers(), ...r.rows[0] });
+ } catch (e) {
+ res.status(500).json({ ok: false, error: e.message });
+ }
+});
+
+app.post('/run/:enricher', (req, res) => {
+ const id = String(req.params.enricher);
+ if (!listEnrichers().includes(id)) {
+ return res.status(404).json({ error: `unknown enricher '${id}'`, available: listEnrichers() });
+ }
+ const env = { ...process.env };
+ if (req.body?.limit) env.LIMIT = String(req.body.limit);
+ const child = spawn('node', [path.join(ENRICHERS_DIR, `${id}.js`)], {
+ cwd: path.resolve(__dirname, '../..'),
+ env,
+ detached: true,
+ stdio: 'ignore',
+ });
+ child.unref();
+ res.json({ ok: true, started: id, pid: child.pid });
+});
+
+app.listen(PORT, '0.0.0.0', () => {
+ console.log(`[pd-enrich] listening on http://127.0.0.1:${PORT}`);
+});
diff --git a/agents/ingest-agent/importers/ccld.js b/agents/ingest-agent/importers/ccld.js
new file mode 100644
index 0000000..0199330
--- /dev/null
+++ b/agents/ingest-agent/importers/ccld.js
@@ -0,0 +1,188 @@
+#!/usr/bin/env node
+/**
+ * CA DSS Community Care Licensing — facility importer.
+ *
+ * Pulls public CSVs from data.ca.gov for the three resident/elder-relevant
+ * datasets:
+ * - Residential Care Facilities for the Elderly (RCFEs + CCRCs) → 'rcfe' / 'ccrc'
+ * - Adult Residential Facilities → 'adult_residential' + variants
+ * - Home Care Organizations → 'home_care_agency'
+ *
+ * Filters to LA County. Upserts on `cdph_license` (= facility_number).
+ *
+ * Run:
+ * node agents/ingest-agent/importers/ccld.js # all three
+ * DATASET=rcfe node ccld.js # one only
+ */
+const fs = require('node:fs');
+const path = require('node:path');
+const { parse } = require('csv-parse');
+const { pool, query, withTx } = require('../../shared/db');
+
+const SOURCE_NAME = 'CA DSS Community Care Licensing';
+const DATA_DIR = path.resolve(__dirname, '../../../data/ccld');
+
+const FEEDS = {
+ rcfe: {
+ file: 'rcfe.csv',
+ url: 'https://data.chhs.ca.gov/dataset/46ffcbdf-4874-4cc1-92c2-fb715e3ad014/resource/744d1583-f9eb-45b6-b0f8-b9a9dab936a6/download/tmpacjmwy9v.csv',
+ },
+ arf: {
+ file: 'arf.csv',
+ url: 'https://data.chhs.ca.gov/dataset/46ffcbdf-4874-4cc1-92c2-fb715e3ad014/resource/9f5d1d00-6b24-4f44-a158-9cbe4b43f117/download/tmpx8kml5z4.csv',
+ },
+ hco: {
+ file: 'home_care.csv',
+ url: 'https://data.chhs.ca.gov/dataset/46ffcbdf-4874-4cc1-92c2-fb715e3ad014/resource/b4d78b7f-12df-4b0c-a81a-ff40b949bc75/download/tmpbgsqj_4n.csv',
+ },
+};
+
+const FACILITY_TYPE_MAP = {
+ 'RESIDENTIAL CARE ELDERLY': 'rcfe',
+ 'RCFE-CONTINUING CARE RETIREMENT COMMUNITY': 'ccrc',
+ 'ADULT RESIDENTIAL': 'adult_residential',
+ 'ADULT DAY PROGRAM': 'adult_day_program',
+ 'SOCIAL REHABILITATION FACILITY': 'social_rehab',
+ 'ENHANCED BEHAVIORAL SUPPORTS HOME - ARF': 'arf_behavioral',
+ 'ADULT RESIDENTIAL FACILITY FOR PERSONS WITH SPECIAL HEALTH CARE NEEDS': 'arf_special_needs',
+ 'COMMUNITY CRISIS HOME - ARF': 'arf_crisis',
+ 'RESIDENTIAL FACILITY CHRONICALLY ILL': 'arf_chronic_ill',
+ 'HOME CARE': 'home_care_agency',
+};
+
+function clean(s) {
+ if (s === undefined || s === null) return null;
+ const t = String(s).trim();
+ return t.length === 0 ? null : t;
+}
+
+function zip5(z) {
+ if (!z) return null;
+ const m = String(z).match(/\d{5}/);
+ return m ? m[0] : null;
+}
+
+function digits(s) { return s ? String(s).replace(/[^0-9]/g, '') : null; }
+
+async function getSourceId() {
+ const r = await query('SELECT id FROM sources WHERE source_name = $1', [SOURCE_NAME]);
+ if (r.rowCount === 0) throw new Error(`Source not seeded: ${SOURCE_NAME}`);
+ return r.rows[0].id;
+}
+
+async function startJob(sourceId, label) {
+ const r = await query(`
+ INSERT INTO scrape_jobs (source_id, job_label, status, started_at)
+ VALUES ($1,$2,'running',NOW()) RETURNING id`,
+ [sourceId, label]);
+ return r.rows[0].id;
+}
+
+async function finishJob(jobId, fields) {
+ const sets = []; const params = []; let i = 1;
+ for (const [k, v] of Object.entries(fields)) { sets.push(`${k} = $${i++}`); params.push(v); }
+ sets.push(`finished_at = NOW()`);
+ params.push(jobId);
+ await query(`UPDATE scrape_jobs SET ${sets.join(', ')} WHERE id = $${i}`, params);
+}
+
+async function ensureFile(feed) {
+ const localPath = path.join(DATA_DIR, feed.file);
+ if (fs.existsSync(localPath) && fs.statSync(localPath).size > 0) return localPath;
+ fs.mkdirSync(DATA_DIR, { recursive: true });
+ console.log(`[ccld] downloading ${feed.url}`);
+ const { fetch } = require('undici');
+ const res = await fetch(feed.url);
+ if (!res.ok) throw new Error(`download failed ${res.status}`);
+ const buf = Buffer.from(await res.arrayBuffer());
+ fs.writeFileSync(localPath, buf);
+ return localPath;
+}
+
+async function upsertOrg(client, row, sourceUrl) {
+ const facilityType = clean(row.facility_type);
+ const orgType = FACILITY_TYPE_MAP[facilityType?.toUpperCase()] || 'care_facility';
+ const name = clean(row.facility_name);
+ const license = clean(row.facility_number);
+ if (!name || !license) return null;
+
+ const phone = digits(row.facility_telephone_number);
+ const r = await client.query(`
+ INSERT INTO organizations (
+ name, type, address, city, state, zip, county, phone,
+ cdph_license
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
+ ON CONFLICT (cdph_license) WHERE cdph_license IS NOT NULL DO UPDATE SET
+ name = EXCLUDED.name,
+ type = EXCLUDED.type,
+ address = COALESCE(EXCLUDED.address, organizations.address),
+ city = COALESCE(EXCLUDED.city, organizations.city),
+ zip = COALESCE(EXCLUDED.zip, organizations.zip),
+ phone = COALESCE(EXCLUDED.phone, organizations.phone),
+ updated_at = NOW()
+ RETURNING id, (xmax = 0) AS inserted
+ `, [
+ name, orgType,
+ clean(row.facility_address),
+ clean(row.facility_city),
+ clean(row.facility_state) || 'CA',
+ zip5(row.facility_zip),
+ 'Los Angeles',
+ phone,
+ license,
+ ]);
+ return { id: r.rows[0].id, inserted: r.rows[0].inserted };
+}
+
+async function importFeed(feedKey, feed, sourceId) {
+ const file = await ensureFile(feed);
+ console.log(`[ccld:${feedKey}] file=${file}`);
+ const jobId = await startJob(sourceId, `ccld:${feedKey}`);
+
+ let scanned = 0, kept = 0, inserted = 0, updated = 0, skipped = 0;
+ const parser = fs.createReadStream(file).pipe(parse({
+ columns: true, skip_empty_lines: true, relax_column_count: true, bom: true,
+ }));
+
+ for await (const row of parser) {
+ scanned++;
+ const county = (row.county_name || '').trim().toUpperCase();
+ const status = (row.facility_status || '').trim().toUpperCase();
+ if (county !== 'LOS ANGELES') { skipped++; continue; }
+ if (status && status !== 'LICENSED') { skipped++; continue; }
+ kept++;
+ try {
+ await withTx(async (client) => {
+ const r = await upsertOrg(client, row, feed.url);
+ if (!r) { skipped++; return; }
+ if (r.inserted) inserted++; else updated++;
+ });
+ } catch (e) {
+ console.error(`[ccld:${feedKey}] row error fac=${row.facility_number}: ${e.message}`);
+ skipped++;
+ }
+ if (kept % 500 === 0) console.log(`[ccld:${feedKey}] scanned=${scanned} kept=${kept} ins=${inserted} upd=${updated} skip=${skipped}`);
+ }
+
+ await finishJob(jobId, {
+ status: 'done',
+ records_found: scanned,
+ records_inserted: inserted,
+ records_updated: updated,
+ records_skipped: skipped,
+ });
+ console.log(`[ccld:${feedKey}] done. scanned=${scanned} kept=${kept} ins=${inserted} upd=${updated} skip=${skipped}`);
+}
+
+async function main() {
+ const sourceId = await getSourceId();
+ const which = process.env.DATASET ? [process.env.DATASET] : Object.keys(FEEDS);
+ for (const k of which) {
+ const feed = FEEDS[k];
+ if (!feed) { console.error(`[ccld] unknown DATASET=${k} (allowed: ${Object.keys(FEEDS).join(',')})`); continue; }
+ await importFeed(k, feed, sourceId);
+ }
+ await pool.end();
+}
+
+main().catch(async (e) => { console.error('[ccld] fatal:', e); try { await pool.end(); } catch (_) {} process.exit(1); });
diff --git a/agents/ingest-agent/importers/cms-care-compare.js b/agents/ingest-agent/importers/cms-care-compare.js
index 5a38d6e..7174235 100644
--- a/agents/ingest-agent/importers/cms-care-compare.js
+++ b/agents/ingest-agent/importers/cms-care-compare.js
@@ -127,9 +127,11 @@ async function applyDac(client, professionalId, row, sourceId) {
`, [professionalId, cmsSpecialty, credentials, gender ? gender.charAt(0).toUpperCase() : null]);
// Group practice as an organization + affiliation.
- const groupName = pick(row, 'org_nm', 'Organization legal name', 'Group name');
- const groupZip = zip5(pick(row, 'adr_zip', 'ZIP Code', 'org_zip'));
- const groupCity = pick(row, 'cty', 'City', 'org_cty');
+ // NB: the CMS DAC CSV uses 'Facility Name' for the group name (not 'org_nm'); 'org_nm'
+ // appears in the older DAC schema. Keep both for forward-compat.
+ const groupName = pick(row, 'Facility Name', 'org_nm', 'Organization legal name', 'Group name');
+ const groupZip = zip5(pick(row, 'ZIP Code', 'adr_zip', 'org_zip'));
+ const groupCity = pick(row, 'City/Town', 'cty', 'City', 'org_cty');
const groupAddr = pick(row, 'adr_ln_1', 'Address Line 1', 'org_adr_ln_1');
if (groupName) {
const orgId = await ensureGroup(client, groupName, groupAddr, groupCity, 'CA', groupZip,
@@ -142,14 +144,15 @@ async function applyDac(client, professionalId, row, sourceId) {
`, [professionalId, orgId, groupAddr, SOURCE_URL]);
}
- // Hospital affiliation.
+ // Hospital affiliation. NB: `IN (..., NULL)` does not match NULLs in SQL —
+ // use OR ... IS NULL to also promote rows whose type was never set.
const hospName = pick(row, 'hosp_afl_1', 'Hospital affiliation name 1');
const hospCcn = pick(row, 'hosp_afl_lbn_1', 'Hospital affiliation CCN 1');
if (hospName) {
const hospId = await ensureGroup(client, hospName, null, null, 'CA', null, hospCcn);
await client.query(`
UPDATE organizations SET type = 'hospital', updated_at = NOW()
- WHERE id = $1 AND type IN ('medical_group','clinic',NULL)
+ WHERE id = $1 AND (type IN ('medical_group','clinic') OR type IS NULL)
`, [hospId]);
await client.query(`
INSERT INTO professional_locations
diff --git a/agents/ingest-agent/importers/hrsa.js b/agents/ingest-agent/importers/hrsa.js
index 7bca92e..031594f 100644
--- a/agents/ingest-agent/importers/hrsa.js
+++ b/agents/ingest-agent/importers/hrsa.js
@@ -21,8 +21,8 @@ const SOURCE_NAME = 'HRSA FQHC Locator';
const SOURCE_URL = 'https://data.hrsa.gov/data/download';
// HRSA's stable API endpoint for "Health Center Service Delivery Sites" CSV.
const CSV_URL = process.env.HRSA_CSV_URL
- || 'https://data.hrsa.gov//DataDownload/HC/HC_Site.csv';
-const CACHE = path.resolve(__dirname, '../../../data/hrsa/HC_Site.csv');
+ || 'https://data.hrsa.gov/DataDownload/DD_Files/Health_Center_Service_Delivery_and_LookAlike_Sites.csv';
+const CACHE = path.resolve(__dirname, '../../../data/hrsa/Health_Center_Service_Delivery_and_LookAlike_Sites.csv');
function clean(s) {
if (s === undefined || s === null) return null;
diff --git a/agents/ingest-agent/importers/osm-smallbiz.js b/agents/ingest-agent/importers/osm-smallbiz.js
new file mode 100644
index 0000000..ff5ab8f
--- /dev/null
+++ b/agents/ingest-agent/importers/osm-smallbiz.js
@@ -0,0 +1,390 @@
+#!/usr/bin/env node
+/**
+ * OSM Overpass — small-business importer.
+ *
+ * Pulls LA County storefronts for the categories Steve sells themed websites to:
+ * • Cosmetology (hair, beauty, barbershop, nail salon)
+ * • Chiropractic offices
+ * • Chinese medicine / acupuncture / TCM
+ *
+ * OSM is the right surface (not licensee registries) because the customer is
+ * the BUSINESS owner — we want storefronts with addresses, phones, websites.
+ * Free, no key, polite-use rate limit per OSM Foundation operations policy.
+ *
+ * Each category writes a distinct organizations.type so the API can filter:
+ * salon | barbershop | nail_salon | beauty_supply
+ * chiropractor_office
+ * acupuncture_clinic | tcm_clinic
+ *
+ * Run:
+ * node agents/ingest-agent/importers/osm-smallbiz.js # all three
+ * CATEGORY=cosmetology node agents/ingest-agent/importers/osm-smallbiz.js
+ * CATEGORY=chiropractic ...
+ * CATEGORY=chinese_medicine ...
+ */
+const crypto = require('node:crypto');
+const { fetch } = require('undici');
+const { pool, query, withTx } = require('../../shared/db');
+
+const SOURCE_NAME = 'OpenStreetMap Overpass API';
+const OVERPASS = process.env.OVERPASS_URL || 'https://overpass-api.de/api/interpreter';
+const USER_AGENT = process.env.USER_AGENT
+ || 'ProfessionalDirectoryBot/0.1 (small-biz directory; contact: steve@designerwallcoverings.com)';
+
+// ─── Overpass queries (LA County = Wikidata Q104994) ────────────────────────
+
+const QUERIES = {
+ cosmetology: `
+[out:json][timeout:90];
+area["wikidata"="Q104994"]->.la;
+(
+ nwr["shop"="hairdresser"](area.la);
+ nwr["shop"="beauty"](area.la);
+ nwr["shop"="cosmetics"](area.la);
+ nwr["shop"="nails"](area.la);
+ nwr["amenity"="beauty_salon"](area.la);
+ nwr["leisure"="spa"](area.la);
+);
+out center tags;
+`.trim(),
+
+ dental: `
+[out:json][timeout:90];
+area["wikidata"="Q104994"]->.la;
+(
+ nwr["healthcare"="dentist"](area.la);
+ nwr["amenity"="dentist"](area.la);
+ nwr["office"="dentist"](area.la);
+);
+out center tags;
+`.trim(),
+
+ optometry: `
+[out:json][timeout:90];
+area["wikidata"="Q104994"]->.la;
+(
+ nwr["healthcare"="optometrist"](area.la);
+ nwr["shop"="optician"](area.la);
+ nwr["amenity"="optometrist"](area.la);
+);
+out center tags;
+`.trim(),
+
+ fitness: `
+[out:json][timeout:90];
+area["wikidata"="Q104994"]->.la;
+(
+ nwr["leisure"="fitness_centre"](area.la);
+ nwr["sport"="yoga"](area.la);
+ nwr["leisure"="sports_centre"]["sport"~"yoga|pilates"](area.la);
+ nwr["amenity"="gym"](area.la);
+);
+out center tags;
+`.trim(),
+
+ tattoo: `
+[out:json][timeout:90];
+area["wikidata"="Q104994"]->.la;
+(
+ nwr["shop"="tattoo"](area.la);
+ nwr["shop"="piercing"](area.la);
+);
+out center tags;
+`.trim(),
+
+ pets: `
+[out:json][timeout:90];
+area["wikidata"="Q104994"]->.la;
+(
+ nwr["shop"="pet_grooming"](area.la);
+ nwr["shop"="pet"](area.la);
+ nwr["amenity"="veterinary"](area.la);
+ nwr["healthcare"="veterinary"](area.la);
+);
+out center tags;
+`.trim(),
+
+ // Chiropractic — name regex catches the long tail; OSM tagging is inconsistent.
+ chiropractic: `
+[out:json][timeout:90];
+area["wikidata"="Q104994"]->.la;
+nwr["name"~"chiropract",i](area.la);
+out center tags;
+`.trim(),
+
+ // Chinese medicine — Overpass union semantics misbehave when name-regex and
+ // structural tag queries combine, so we run pieces and merge client-side.
+ chinese_medicine: [
+ `[out:json][timeout:60];area["wikidata"="Q104994"]->.la;nwr["name"~"acupunctur",i](area.la);out center tags;`,
+ `[out:json][timeout:60];area["wikidata"="Q104994"]->.la;nwr["name"~"chinese medicin",i](area.la);out center tags;`,
+ `[out:json][timeout:60];area["wikidata"="Q104994"]->.la;nwr["name"~"oriental medicin",i](area.la);out center tags;`,
+ `[out:json][timeout:60];area["wikidata"="Q104994"]->.la;nwr["name"~"herbal",i](area.la);out center tags;`,
+ `[out:json][timeout:60];area["wikidata"="Q104994"]->.la;nwr["healthcare"="alternative"](area.la);out center tags;`,
+ ],
+};
+
+// ─── Type mapping per element tags ──────────────────────────────────────────
+
+function typeForTags(category, tags) {
+ if (category === 'cosmetology') {
+ if (tags.shop === 'nails') return 'nail_salon';
+ if (tags.shop === 'cosmetics') return 'beauty_supply';
+ if (tags.shop === 'hairdresser' && /barber/i.test(tags.name || '')) return 'barbershop';
+ if (tags.shop === 'hairdresser') return 'salon';
+ if (tags.shop === 'beauty') return 'salon';
+ if (tags.amenity === 'beauty_salon') return 'salon';
+ if (tags.leisure === 'spa') return 'spa';
+ return 'salon';
+ }
+ if (category === 'chiropractic') return 'chiropractor_office';
+ if (category === 'chinese_medicine') {
+ if (tags.alternative === 'acupuncture' || /acupunc/i.test(tags.name || '')) return 'acupuncture_clinic';
+ if (tags.shop === 'herbalist') return 'tcm_herbalist';
+ return 'tcm_clinic';
+ }
+ if (category === 'dental') return 'dental_office';
+ if (category === 'optometry') {
+ if (tags.shop === 'optician') return 'optical_shop';
+ return 'optometrist_office';
+ }
+ if (category === 'fitness') {
+ if (tags.sport === 'yoga' || /yoga/i.test(tags.name || '')) return 'yoga_studio';
+ if (/pilates/i.test(tags.name || '') || tags.sport === 'pilates') return 'pilates_studio';
+ return 'gym';
+ }
+ if (category === 'tattoo') {
+ if (tags.shop === 'piercing') return 'piercing_studio';
+ return 'tattoo_studio';
+ }
+ if (category === 'pets') {
+ if (tags.amenity === 'veterinary' || tags.healthcare === 'veterinary') return 'vet_clinic';
+ if (tags.shop === 'pet_grooming') return 'pet_grooming';
+ return 'pet_shop';
+ }
+ return 'small_biz';
+}
+
+// ─── Helpers ────────────────────────────────────────────────────────────────
+
+function sha256(s) { return crypto.createHash('sha256').update(s).digest('hex'); }
+
+function clean(s) {
+ if (s === undefined || s === null) return null;
+ const t = String(s).trim();
+ return t.length === 0 ? null : t;
+}
+
+function buildAddress(t) {
+ const street = [t['addr:housenumber'], t['addr:street']].filter(Boolean).join(' ');
+ const unit = t['addr:unit'] ? ` Suite ${t['addr:unit']}` : '';
+ const line1 = clean(street + unit);
+ const city = clean(t['addr:city']);
+ const state = clean(t['addr:state']) || 'CA';
+ const zip = clean(t['addr:postcode']);
+ const full = (line1 || city) ? [line1, city, state, zip].filter(Boolean).join(', ') : null;
+ return { line1, city, state, zip, full };
+}
+
+function normAddress(s) {
+ if (!s) return null;
+ return s.toLowerCase().replace(/[.,]/g, ' ').replace(/\b(suite|ste|unit|apt|#)\b/g, '')
+ .replace(/\s+/g, ' ').trim();
+}
+
+async function fetchOverpass(ql) {
+ const body = `data=${encodeURIComponent(ql)}`;
+ const r = await fetch(OVERPASS, {
+ method: 'POST',
+ headers: {
+ 'User-Agent': USER_AGENT,
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Accept: 'application/json',
+ },
+ body,
+ signal: AbortSignal.timeout(180000),
+ });
+ if (!r.ok) {
+ const txt = await r.text();
+ throw new Error(`Overpass ${r.status}: ${txt.slice(0, 400)}`);
+ }
+ const json = await r.json();
+ return json.elements || [];
+}
+
+async function getSourceId() {
+ const r = await query('SELECT id FROM sources WHERE source_name = $1', [SOURCE_NAME]);
+ if (r.rowCount === 0) throw new Error(`source not seeded: ${SOURCE_NAME}`);
+ return r.rows[0].id;
+}
+
+async function startJob(sourceId, label) {
+ const r = await query(
+ `INSERT INTO scrape_jobs (source_id, job_label, status, started_at)
+ VALUES ($1,$2,'running',NOW()) RETURNING id`,
+ [sourceId, label]
+ );
+ return r.rows[0].id;
+}
+
+async function finishJob(jobId, fields) {
+ const sets = []; const params = []; let i = 1;
+ for (const [k, v] of Object.entries(fields)) { sets.push(`${k} = $${i++}`); params.push(v); }
+ sets.push(`finished_at = NOW()`); params.push(jobId);
+ await query(`UPDATE scrape_jobs SET ${sets.join(', ')} WHERE id = $${i}`, params);
+}
+
+async function upsert(el, category, sourceId) {
+ const tags = el.tags || {};
+ const name = clean(tags.name);
+ if (!name) return null;
+
+ const lat = el.lat ?? el.center?.lat ?? null;
+ const lng = el.lon ?? el.center?.lon ?? null;
+ const addr = buildAddress(tags);
+ const phone = clean(tags.phone) || clean(tags['contact:phone']);
+ const website = clean(tags.website) || clean(tags['contact:website']);
+ const email = clean(tags.email) || clean(tags['contact:email']);
+ const sourceUrl = `https://www.openstreetmap.org/${el.type}/${el.id}`;
+ const type = typeForTags(category, tags);
+
+ return await withTx(async (client) => {
+ // Find existing by website first (stable), then name + address_norm + zip.
+ let orgId = null;
+ if (website) {
+ const r = await client.query(
+ `SELECT id FROM organizations WHERE LOWER(website) = LOWER($1) LIMIT 1`, [website]);
+ if (r.rowCount) orgId = r.rows[0].id;
+ }
+ if (!orgId && addr.full) {
+ const r = await client.query(
+ `SELECT id FROM organizations
+ WHERE LOWER(name) = LOWER($1)
+ AND zip IS NOT DISTINCT FROM $2
+ LIMIT 1`,
+ [name, addr.zip]);
+ if (r.rowCount) orgId = r.rows[0].id;
+ }
+
+ if (orgId) {
+ await client.query(`
+ UPDATE organizations SET
+ name = $2,
+ type = COALESCE(NULLIF($3,''), type),
+ address = COALESCE($4, address),
+ city = COALESCE($5, city),
+ zip = COALESCE($6, zip),
+ county = COALESCE($7, county),
+ phone = COALESCE($8, phone),
+ website = COALESCE($9, website),
+ lat = COALESCE($10::double precision, lat),
+ lng = COALESCE($11::double precision, lng),
+ geocoded_at = CASE WHEN $10::double precision IS NOT NULL
+ AND $11::double precision IS NOT NULL THEN NOW()
+ ELSE geocoded_at END,
+ updated_at = NOW()
+ WHERE id = $1
+ `, [orgId, name, type, addr.full, addr.city, addr.zip, 'Los Angeles',
+ phone, website, lat, lng]);
+ } else {
+ const r = await client.query(`
+ INSERT INTO organizations
+ (name, type, address, city, state, zip, county, phone, website,
+ lat, lng, geocoded_at)
+ VALUES ($1,$2,$3,$4,'CA',$5,'Los Angeles',$6,$7,
+ $8::double precision, $9::double precision,
+ CASE WHEN $8::double precision IS NOT NULL
+ AND $9::double precision IS NOT NULL THEN NOW() END)
+ RETURNING id
+ `, [name, type, addr.full, addr.city, addr.zip, phone, website, lat, lng]);
+ orgId = r.rows[0].id;
+ }
+
+ if (email) {
+ await client.query(`
+ INSERT INTO emails (organization_id, email, email_type, source_url, last_verified_at)
+ VALUES ($1,$2,'office',$3,NOW())
+ `, [orgId, email, sourceUrl]);
+ }
+
+ // Provenance.
+ const json = JSON.stringify({ ...el, tags });
+ const hash = sha256(json + '|organization|' + orgId);
+ await client.query(`
+ INSERT INTO raw_records (source_id, source_url, entity_type, entity_id, raw_json, hash)
+ VALUES ($1,$2,'organization',$3,$4::jsonb,$5)
+ ON CONFLICT (hash) DO NOTHING
+ `, [sourceId, sourceUrl, orgId, json, hash]);
+
+ return orgId;
+ });
+}
+
+async function runCategory(category, sourceId) {
+ const ql = QUERIES[category];
+ if (!ql) throw new Error(`unknown category: ${category}`);
+ console.log(`[osm:${category}] querying Overpass…`);
+ const jobId = await startJob(sourceId, `osm:smallbiz:${category}`);
+
+ // ql can be a single string or an array of strings (multi-query union).
+ const queries = Array.isArray(ql) ? ql : [ql];
+ let elements = [];
+ const seenKey = new Set();
+ try {
+ for (const q of queries) {
+ const els = await fetchOverpass(q);
+ for (const el of els) {
+ const k = `${el.type}/${el.id}`;
+ if (seenKey.has(k)) continue;
+ seenKey.add(k);
+ elements.push(el);
+ }
+ if (queries.length > 1) await new Promise(res => setTimeout(res, 1000));
+ }
+ } catch (e) {
+ await finishJob(jobId, { status: 'failed', error_message: e.message });
+ throw e;
+ }
+ console.log(`[osm:${category}] received ${elements.length} elements (deduped)`);
+
+ let inserted = 0, skipped = 0;
+ for (const el of elements) {
+ try {
+ const id = await upsert(el, category, sourceId);
+ if (id) inserted++; else skipped++;
+ } catch (e) {
+ console.error(`[osm:${category}] upsert err ${el.type}/${el.id}: ${e.message}`);
+ skipped++;
+ }
+ }
+
+ await finishJob(jobId, {
+ status: 'done',
+ records_found: elements.length,
+ records_inserted: inserted,
+ records_skipped: skipped,
+ });
+ console.log(`[osm:${category}] done. seen=${elements.length} kept=${inserted} skipped=${skipped}`);
+ return { category, seen: elements.length, kept: inserted, skipped };
+}
+
+async function main() {
+ const sourceId = await getSourceId();
+ const only = process.env.CATEGORY ? [process.env.CATEGORY] : Object.keys(QUERIES);
+
+ const results = [];
+ for (const cat of only) {
+ const r = await runCategory(cat, sourceId);
+ results.push(r);
+ // Polite: 2s between bulk Overpass queries.
+ await new Promise(res => setTimeout(res, 2000));
+ }
+
+ console.table(results);
+ await pool.end();
+}
+
+main().catch(async (err) => {
+ console.error('[osm] fatal:', err);
+ try { await pool.end(); } catch (_) {}
+ process.exit(1);
+});
diff --git a/agents/ingest-agent/server.js b/agents/ingest-agent/server.js
new file mode 100644
index 0000000..2c7123c
--- /dev/null
+++ b/agents/ingest-agent/server.js
@@ -0,0 +1,86 @@
+#!/usr/bin/env node
+/**
+ * Ingest agent — HTTP control plane for bulk importers.
+ *
+ * GET /health — { ok, agent, db, jobs: [recent] }
+ * GET /jobs — recent scrape_jobs rows
+ * POST /run/:importer — async-fire one of: npi | mbc | hcai | hrsa | cms-care-compare | dca
+ *
+ * Importers themselves live in importers/<name>.js and run as child processes.
+ * This server is intentionally thin — heavy work is one-shot Node scripts.
+ */
+const path = require('path');
+const { spawn } = require('node:child_process');
+require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
+const express = require('express');
+const { pool, query } = require('../shared/db');
+
+const PORT = Number(process.env.PORT_INGEST || process.env.PORT || 9870);
+const app = express();
+app.use(express.json());
+
+// LAN/tailnet auth gate (skip /health for watchdog probes)
+const BASIC_AUTH = process.env.BASIC_AUTH || 'admin:DWSecure2024!';
+const AUTH_HEADER = 'Basic ' + Buffer.from(BASIC_AUTH).toString('base64');
+app.use((req, res, next) => {
+ if (req.path === '/health' || req.path === '/healthz') return next();
+ if (req.get('authorization') === AUTH_HEADER) return next();
+ res.set('WWW-Authenticate', 'Basic realm="mac2-pm2"');
+ res.status(401).send('auth required');
+});
+
+const IMPORTERS = ['npi', 'mbc', 'hcai', 'hrsa', 'cms-care-compare', 'dca', 'cms-hospitals'];
+
+app.get('/health', async (_req, res) => {
+ try {
+ const r = await query(`
+ SELECT id, job_label, status, records_inserted, records_updated, started_at, finished_at
+ FROM scrape_jobs ORDER BY id DESC LIMIT 5
+ `, []);
+ res.json({ ok: true, agent: 'pd-ingest', db: 'doctor_professional_directory', jobs: r.rows });
+ } catch (e) {
+ res.status(500).json({ ok: false, error: e.message });
+ }
+});
+
+app.get('/jobs', async (_req, res, next) => {
+ try {
+ const r = await query(`
+ SELECT j.*, s.source_name FROM scrape_jobs j
+ LEFT JOIN sources s ON s.id = j.source_id
+ ORDER BY j.id DESC LIMIT 50
+ `, []);
+ res.json({ count: r.rowCount, rows: r.rows });
+ } catch (e) { next(e); }
+});
+
+// SECURITY: dropped req.body.env passthrough (was caller-controlled env merge
+// into spawned child; could overwrite NODE_PATH / DB / Stripe keys). Added
+// shared-secret token check. INGEST_RUN_TOKEN must be set in pm2 env.
+const INGEST_RUN_TOKEN = process.env.INGEST_RUN_TOKEN || '';
+app.post('/run/:importer', (req, res) => {
+ if (!INGEST_RUN_TOKEN) {
+ return res.status(503).json({ error: 'INGEST_RUN_TOKEN not configured on server' });
+ }
+ const auth = String(req.get('authorization') || '');
+ if (auth !== `Bearer ${INGEST_RUN_TOKEN}`) {
+ return res.status(401).json({ error: 'unauthorized' });
+ }
+ const name = String(req.params.importer);
+ if (!IMPORTERS.includes(name)) {
+ return res.status(400).json({ error: `unknown importer; allowed: ${IMPORTERS.join(', ')}` });
+ }
+ const script = path.join(__dirname, 'importers', `${name}.js`);
+ const child = spawn('node', [script], {
+ cwd: path.resolve(__dirname, '../..'),
+ env: process.env, // NO caller-supplied env merge
+ detached: true,
+ stdio: 'ignore',
+ });
+ child.unref();
+ res.json({ ok: true, started: name, pid: child.pid });
+});
+
+app.listen(PORT, '0.0.0.0', () => {
+ console.log(`[pd-ingest] listening on http://127.0.0.1:${PORT}`);
+});
diff --git a/agents/preview-agent/server.js b/agents/preview-agent/server.js
new file mode 100644
index 0000000..e6cf140
--- /dev/null
+++ b/agents/preview-agent/server.js
@@ -0,0 +1,81 @@
+#!/usr/bin/env node
+/**
+ * Public preview server — pd-preview.
+ *
+ * The ONLY service in the pd-* fleet intended to face the public internet
+ * (via Cloudflare tunnel or similar). It exposes exactly two routes:
+ *
+ * GET /preview/:id — proxies to pd-api at http://127.0.0.1:9874/preview/:id
+ * (numeric id only)
+ * GET /favicon.ico — empty 204
+ *
+ * Everything else → 404. No DB access, no admin, no API surface beyond the
+ * single read-through. pd-api stays private on 127.0.0.1:9874 so its
+ * professional/org/email APIs and admin dashboard never leave the box.
+ *
+ * Run:
+ * PORT=9875 node agents/preview-agent/server.js
+ * (autostarted under pm2 as pd-preview via ecosystem.config.js)
+ */
+const path = require('path');
+require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
+const express = require('express');
+const { fetch } = require('undici');
+
+const PORT = Number(process.env.PORT_PREVIEW || process.env.PORT || 9875);
+const UPSTREAM = process.env.PD_API_BASE || 'http://127.0.0.1:9874';
+
+const app = express();
+app.disable('x-powered-by');
+
+// Light request logging: log path + status + ms — useful when this is the
+// thing actually facing the public internet.
+app.use((req, res, next) => {
+ const t = Date.now();
+ res.on('finish', () => {
+ console.log(`[pd-preview] ${req.method} ${req.path} ${res.statusCode} ${Date.now() - t}ms ${req.ip}`);
+ });
+ next();
+});
+
+// Cap requests to prevent ridiculous URLs / bot probing.
+app.use((req, res, next) => {
+ if (req.path.length > 200) return res.status(414).type('text').send('URI too long');
+ next();
+});
+
+app.get('/health', (_req, res) => res.json({ ok: true, agent: 'pd-preview', upstream: UPSTREAM }));
+
+app.get('/favicon.ico', (_req, res) => res.status(204).end());
+
+app.get('/preview/:id', async (req, res) => {
+ const id = String(req.params.id);
+ if (!/^\d+$/.test(id)) return res.status(404).type('text').send('Not found');
+ try {
+ const upstream = await fetch(`${UPSTREAM}/preview/${id}`, {
+ headers: { 'User-Agent': 'pd-preview/0.1' },
+ signal: AbortSignal.timeout(8_000),
+ });
+ if (upstream.status === 404) return res.status(404).type('text').send('Not found');
+ if (!upstream.ok) return res.status(502).type('text').send('Upstream error');
+ res.status(200);
+ res.setHeader('Content-Type', upstream.headers.get('content-type') || 'text/html; charset=utf-8');
+ // Serve through CDN cache for 5 minutes — these pages are deterministic
+ // for a given org and Steve will rarely change underlying data per-org.
+ res.setHeader('Cache-Control', 'public, max-age=300, s-maxage=600');
+ // Allow embedding (the dashboard pitch modal iframes this page).
+ res.setHeader('X-Frame-Options', 'SAMEORIGIN');
+ const body = await upstream.text();
+ res.end(body);
+ } catch (e) {
+ res.status(504).type('text').send('Upstream timeout');
+ }
+});
+
+// Catch-all → 404 plain.
+app.use((_req, res) => res.status(404).type('text').send('Not found'));
+
+const HOST = process.env.PD_PREVIEW_BIND || '127.0.0.1';
+app.listen(PORT, HOST, () => {
+ console.log(`[pd-preview] listening on http://${HOST}:${PORT} upstream=${UPSTREAM}`);
+});
diff --git a/agents/shared/compliance.js b/agents/shared/compliance.js
index f62f4de..2dcc8d9 100644
--- a/agents/shared/compliance.js
+++ b/agents/shared/compliance.js
@@ -19,50 +19,74 @@ const DEFAULT_RPS = parseFloat(process.env.DEFAULT_RATE_LIMIT_RPS || '0.5');
// ─── Per-host robots.txt cache ───────────────────────────────────────────────
-const robotsCache = new Map(); // host -> { robots, fetchedAt }
+const robotsCache = new Map(); // protocol+host -> { robots, fetchedAt, denied }
const ONE_DAY = 24 * 60 * 60 * 1000;
async function getRobotsFor(urlStr) {
const u = new URL(urlStr);
- const host = u.host;
- const cached = robotsCache.get(host);
- if (cached && (Date.now() - cached.fetchedAt) < ONE_DAY) return cached.robots;
+ // Cache key includes scheme — robots.txt is per-scheme per RFC 9309.
+ const cacheKey = `${u.protocol}//${u.host}`;
+ const cached = robotsCache.get(cacheKey);
+ if (cached && (Date.now() - cached.fetchedAt) < ONE_DAY) return cached;
- const robotsUrl = `${u.protocol}//${host}/robots.txt`;
+ const robotsUrl = `${cacheKey}/robots.txt`;
let body = '';
+ let denied = false;
try {
const res = await fetch(robotsUrl, {
headers: { 'User-Agent': USER_AGENT },
signal: AbortSignal.timeout(10000),
});
- if (res.ok) body = await res.text();
- } catch (_) { /* treat as permissive on transport error */ }
+ if (res.ok) {
+ body = await res.text();
+ } else if (res.status >= 500) {
+ // RFC 9309: 5xx on robots.txt MUST be treated as full disallow.
+ denied = true;
+ }
+ // 4xx (incl. 404) is permissive per RFC.
+ } catch (_) { /* transport error → permissive */ }
const robots = robotsParser(robotsUrl, body);
- robotsCache.set(host, { robots, fetchedAt: Date.now() });
- return robots;
+ const entry = { robots, fetchedAt: Date.now(), denied };
+ robotsCache.set(cacheKey, entry);
+ return entry;
}
async function isAllowed(urlStr) {
- const robots = await getRobotsFor(urlStr);
+ const { robots, denied } = await getRobotsFor(urlStr);
+ if (denied) return false;
return robots.isAllowed(urlStr, USER_AGENT) !== false;
}
// ─── Per-host rate limiter (token-bucket-ish, simple gating) ─────────────────
+// Keys are normalized via hostKey() so callers may pass either u.host or
+// u.hostname interchangeably without silently bypassing the limit.
-const lastFetchByHost = new Map(); // host -> ms timestamp
+const lastFetchByHost = new Map(); // host -> ms timestamp (next-allowed)
const rpsByHost = new Map(); // host -> rps override
-function setHostRateLimit(host, rps) { rpsByHost.set(host, rps); }
+function hostKey(hostOrUrl) {
+ if (typeof hostOrUrl !== 'string') return String(hostOrUrl);
+ // strip :port; lowercase
+ const lower = hostOrUrl.toLowerCase();
+ const colon = lower.indexOf(':');
+ return colon === -1 ? lower : lower.slice(0, colon);
+}
+
+function setHostRateLimit(host, rps) { rpsByHost.set(hostKey(host), rps); }
async function gateHost(host) {
- const rps = rpsByHost.get(host) ?? DEFAULT_RPS;
+ const k = hostKey(host);
+ const rps = rpsByHost.get(k) ?? DEFAULT_RPS;
const minIntervalMs = 1000 / rps;
- const last = lastFetchByHost.get(host) || 0;
const now = Date.now();
- const wait = Math.max(0, last + minIntervalMs - now);
+ const last = lastFetchByHost.get(k) || 0;
+ // Reserve the slot BEFORE awaiting so concurrent callers serialize against
+ // each other rather than all reading the same `last` and racing.
+ const reservedNext = Math.max(now, last) + minIntervalMs;
+ lastFetchByHost.set(k, reservedNext);
+ const wait = Math.max(0, last - now);
if (wait > 0) await new Promise(r => setTimeout(r, wait));
- lastFetchByHost.set(host, Date.now());
}
// ─── Public fetch wrapper ────────────────────────────────────────────────────
@@ -79,7 +103,7 @@ async function fetchCompliant(urlStr, opts = {}) {
}
}
- await gateHost(u.host);
+ await gateHost(u.hostname);
const headers = {
'User-Agent': USER_AGENT,
diff --git a/agents/shared/db.js b/agents/shared/db.js
index 6e447d7..ad3c117 100644
--- a/agents/shared/db.js
+++ b/agents/shared/db.js
@@ -1,28 +1,37 @@
/**
* Professional Directory — Shared DB pool.
- * Mirrors Norma's shared/db.js. Targets doctor_professional_directory.
+ *
+ * Migrated 2026-05-04 (tick 23 of overnight YOLO loop) from a standalone
+ * `new Pool({...})` to a thin CJS wrapper around directory-core/db. All
+ * @pd/shared consumers continue to `require('@pd/shared')` unchanged.
+ *
+ * Doctor-specific behaviors PRESERVED on top of the shared pool:
+ * - Slow-query warning (>1s)
+ * - Error logging w/ truncated query preview
+ * - pool.on('error') listener
+ * - Default DATABASE_URL fallback if env unset
+ *
+ * CJS-importing-ESM works on Node 22.12+ (require(esm) feature). Mac is
+ * 22.14, Kamatera is 22.22 — both safe.
*/
require('dotenv').config({ path: require('path').resolve(__dirname, '../../.env') });
-const { Pool } = require('pg');
-const url = process.env.DATABASE_URL
- || 'postgresql:///doctor_professional_directory?host=/tmp&user=stevestudio2';
+// Doctor's historical default DATABASE_URL — set BEFORE requiring directory-core/db
+// so its lazy-pool initialization picks it up if .env doesn't override.
+process.env.DATABASE_URL ??= 'postgresql:///doctor_professional_directory?host=/tmp&user=stevestudio2';
-const pool = new Pool({
- connectionString: url,
- max: 10,
- idleTimeoutMillis: 30000,
- connectionTimeoutMillis: 5000,
-});
+// require(esm) — Node 22.12+ feature. directory-core ships ESM-only.
+const { pool, query: coreQuery, withTx } = require('directory-core/db');
pool.on('error', (err) => {
console.error(`[${new Date().toISOString()}] [db] Unexpected pool error:`, err.message);
});
+// Wrap directory-core's query with doctor-specific slow-query + error logging.
async function query(text, params) {
const start = Date.now();
try {
- const result = await pool.query(text, params);
+ const result = await coreQuery(text, params);
const duration = Date.now() - start;
if (duration > 1000) {
console.warn(`[${new Date().toISOString()}] [db] Slow (${duration}ms): ${text.slice(0, 80)}…`);
@@ -35,19 +44,4 @@ async function query(text, params) {
}
}
-async function withTx(fn) {
- const client = await pool.connect();
- try {
- await client.query('BEGIN');
- const out = await fn(client);
- await client.query('COMMIT');
- return out;
- } catch (e) {
- await client.query('ROLLBACK');
- throw e;
- } finally {
- client.release();
- }
-}
-
module.exports = { pool, query, withTx };
diff --git a/agents/shared/package.json b/agents/shared/package.json
index e39cd21..d997e2b 100644
--- a/agents/shared/package.json
+++ b/agents/shared/package.json
@@ -4,6 +4,7 @@
"private": true,
"main": "db.js",
"dependencies": {
+ "directory-core": "file:../../../directory-core",
"dotenv": "^16.4.7",
"pg": "^8.13.1",
"robots-parser": "^3.0.1",
diff --git a/agents/web-agent/app/account/claim/page.tsx b/agents/web-agent/app/account/claim/page.tsx
new file mode 100644
index 0000000..f7bbdfe
--- /dev/null
+++ b/agents/web-agent/app/account/claim/page.tsx
@@ -0,0 +1,319 @@
+// /account/claim — claim-flow form. Linked from /doctors/[slug] and /orgs/[slug].
+//
+// Query params:
+// ?npi=NNNNNNNNNN — claiming an individual professional profile
+// ?org=NNNN — claiming an organization (practice/clinic)
+//
+// Flow:
+// 1. Require auth — if not signed in, deflect to Google OAuth, returnTo here.
+// 2. Resolve NPI → numeric professional id via /api/professionals/:npi
+// (or use ?org=N directly).
+// 3. Show the target's name + auto-approval rules + practice-domain hint.
+// 4. POST /api/doctor-claims with { target_professional_id | target_organization_id,
+// npi, license_number? }. Server emails a verification link to the user's
+// account email (must be the practice-domain email for auto-approve).
+// 5. Show "check your email" success state.
+'use client';
+import { useEffect, useState } from 'react';
+
+type WhoAmI = {
+ user: null | {
+ id: number; email: string; role: string;
+ claimed_professional_id?: number; claimed_organization_id?: number;
+ };
+};
+
+type Target =
+ | { kind: 'pro'; id: number; npi: string; name: string; specialty?: string }
+ | { kind: 'org'; id: number; name: string; website?: string; city?: string };
+
+function cardBtn(disabled = false): React.CSSProperties {
+ return {
+ display: 'inline-block',
+ padding: '12px 22px',
+ background: disabled ? 'var(--ink-soft)' : 'var(--moss)',
+ color: '#fff',
+ border: 0,
+ borderRadius: 999,
+ fontWeight: 600,
+ letterSpacing: '0.04em',
+ textTransform: 'uppercase',
+ fontSize: 14,
+ cursor: disabled ? 'not-allowed' : 'pointer',
+ opacity: disabled ? 0.6 : 1,
+ };
+}
+
+function emailDomain(e: string): string {
+ const at = e.lastIndexOf('@');
+ return at >= 0 ? e.slice(at + 1).toLowerCase() : '';
+}
+function urlDomain(u: string): string {
+ try { return new URL(/^https?:\/\//i.test(u) ? u : 'https://' + u).hostname.toLowerCase().replace(/^www\./, ''); }
+ catch { return ''; }
+}
+
+export default function ClaimPage() {
+ const [me, setMe] = useState<WhoAmI | null>(null);
+ const [target, setTarget] = useState<Target | null>(null);
+ const [loadErr, setLoadErr] = useState<string>('');
+ const [npi, setNpi] = useState('');
+ const [license, setLicense] = useState('');
+ const [submitting, setSubmitting] = useState(false);
+ const [done, setDone] = useState<{ to: string } | null>(null);
+ const [err, setErr] = useState('');
+
+ // 1. whoami + parse query string
+ useEffect(() => {
+ let cancelled = false;
+ fetch('/api/auth/whoami', { credentials: 'include' })
+ .then(r => r.ok ? r.json() : { user: null })
+ .then(j => { if (!cancelled) setMe(j); });
+
+ const qs = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : new URLSearchParams();
+ const npiQ = qs.get('npi');
+ const orgQ = qs.get('org');
+
+ if (npiQ && /^\d{10}$/.test(npiQ)) {
+ setNpi(npiQ);
+ fetch(`/api/professionals/${npiQ}`)
+ .then(r => r.ok ? r.json() : null)
+ .then(p => {
+ if (cancelled) return;
+ if (!p) { setLoadErr(`No professional found with NPI ${npiQ}.`); return; }
+ const specs = Array.isArray(p.specialties) ? p.specialties : [];
+ setTarget({
+ kind: 'pro',
+ id: p.id,
+ npi: p.npi_number,
+ name: p.full_name,
+ specialty: specs[0]?.specialty,
+ });
+ })
+ .catch(() => { if (!cancelled) setLoadErr('Failed to load professional.'); });
+ } else if (orgQ && /^\d+$/.test(orgQ)) {
+ fetch(`/api/organizations/${orgQ}`)
+ .then(r => r.ok ? r.json() : null)
+ .then(o => {
+ if (cancelled) return;
+ if (!o) { setLoadErr(`No organization found with id ${orgQ}.`); return; }
+ setTarget({
+ kind: 'org',
+ id: o.id,
+ name: o.name,
+ website: o.website,
+ city: o.city,
+ });
+ })
+ .catch(() => { if (!cancelled) setLoadErr('Failed to load organization.'); });
+ } else {
+ setLoadErr('Missing or invalid claim target. Use a "Claim this profile" link from a doctor or practice page.');
+ }
+ return () => { cancelled = true; };
+ }, []);
+
+ // Loading
+ if (me === null) {
+ return <section className="section"><h2>Loading…</h2></section>;
+ }
+
+ // Need auth
+ if (!me.user) {
+ const here = typeof window !== 'undefined' ? window.location.pathname + window.location.search : '/account/claim';
+ return (
+ <section className="section">
+ <div className="eyebrow">Claim</div>
+ <h2>Sign in to claim a profile</h2>
+ <p style={{ maxWidth: 560, color: 'var(--ink-soft)' }}>
+ We verify claims by sending a link to your <strong>practice-domain email</strong> (e.g. you@yourclinic.com).
+ Personal Gmail works for sign-in, but auto-approval needs a domain that matches the practice website.
+ </p>
+ <a href={`/api/auth/google?returnTo=${encodeURIComponent(here)}`} style={{ ...cardBtn(), display: 'inline-block', textAlign: 'center', marginTop: 16 }}>
+ Continue with Google →
+ </a>
+ </section>
+ );
+ }
+
+ // Already claimed
+ if (me.user.claimed_professional_id || me.user.claimed_organization_id) {
+ return (
+ <section className="section">
+ <div className="eyebrow">Claim</div>
+ <h2>You already have an active claim</h2>
+ <p style={{ color: 'var(--ink-soft)' }}>
+ Your account is linked to {me.user.claimed_professional_id
+ ? `professional #${me.user.claimed_professional_id}`
+ : `organization #${me.user.claimed_organization_id}`}.
+ To claim an additional profile, contact admin.
+ </p>
+ <a href="/account" style={cardBtn()}>Back to account →</a>
+ </section>
+ );
+ }
+
+ // Bad target / not loaded yet
+ if (loadErr) {
+ return (
+ <section className="section">
+ <div className="eyebrow">Claim</div>
+ <h2>Couldn't load that profile</h2>
+ <p style={{ color: 'var(--ink-soft)' }}>{loadErr}</p>
+ <a href="/search" style={cardBtn()}>Back to search →</a>
+ </section>
+ );
+ }
+ if (!target) {
+ return <section className="section"><h2>Loading profile…</h2></section>;
+ }
+
+ // Success state
+ if (done) {
+ return (
+ <section className="section">
+ <div className="eyebrow">Claim submitted</div>
+ <h2>Check your email</h2>
+ <p style={{ maxWidth: 560, color: 'var(--ink-soft)' }}>
+ We sent a verification link to <strong>{done.to}</strong>. Click it to verify the email.
+ If your email domain matches the practice website, your claim auto-approves on the spot.
+ Otherwise it goes to admin review (we typically respond within 1 business day).
+ </p>
+ <p style={{ color: 'var(--ink-soft)', fontSize: 13 }}>
+ The link expires in 7 days. Didn't get it? Check spam, or reach out to admin via the dashboard.
+ </p>
+ <a href="/account" style={cardBtn()}>Back to account →</a>
+ </section>
+ );
+ }
+
+ // Domain hints
+ const userDomain = emailDomain(me.user.email);
+ const orgDomain = target.kind === 'org' && target.website ? urlDomain(target.website) : '';
+ const domainsMatch = orgDomain && userDomain && orgDomain === userDomain;
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!target) return;
+ setErr('');
+ setSubmitting(true);
+ try {
+ const body: any = {};
+ if (target.kind === 'pro') {
+ body.target_professional_id = target.id;
+ body.npi = (npi || target.npi || '').trim();
+ } else {
+ body.target_organization_id = target.id;
+ }
+ if (license.trim()) body.license_number = license.trim();
+
+ const r = await fetch('/api/doctor-claims', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify(body),
+ });
+ const j = await r.json().catch(() => ({}));
+ if (!r.ok) {
+ setErr(j.error || `Submission failed (HTTP ${r.status}).`);
+ return;
+ }
+ setDone({ to: j.verify_link_sent_to || me!.user!.email });
+ } catch (ex: any) {
+ setErr(ex?.message || 'Network error. Try again.');
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+ <section className="section" style={{ paddingTop: 48 }}>
+ <div className="eyebrow">Claim</div>
+ <h2>Claim {target.name}</h2>
+ <p style={{ maxWidth: 600, color: 'var(--ink-soft)' }}>
+ {target.kind === 'pro'
+ ? <>Verifying you are <strong>{target.name}</strong>{target.specialty ? ` (${target.specialty})` : ''}. After verification you can respond to reviews, edit your page, and DM patients on the paid tier.</>
+ : <>Verifying you manage <strong>{target.name}</strong>{target.city ? ` in ${target.city}` : ''}. After verification you can respond to reviews, edit page content, and run team accounts.</>}
+ </p>
+
+ <div className="card-grid" style={{ marginTop: 32 }}>
+ <div className="card">
+ <span className="badge">Signed in as</span>
+ <span className="name">{me.user.email}</span>
+ <span className="meta">
+ {target.kind === 'org' && orgDomain
+ ? (domainsMatch
+ ? `✓ Your email domain matches ${orgDomain} — claim will auto-approve after you click the verify link.`
+ : `Practice website is ${orgDomain}; your email is @${userDomain}. Auto-approve needs an exact match. Otherwise, queued for admin review.`)
+ : 'Auto-approve needs your email domain to match the practice website. Other claims go to admin review.'}
+ </span>
+ </div>
+ <div className="card">
+ <span className="badge">What we send</span>
+ <span className="name">A verify link to {me.user.email}</span>
+ <span className="meta">
+ Click it within 7 days. NPI {target.kind === 'pro' ? '(required)' : '(optional)'} and CA license (optional)
+ help admin review faster if auto-approve doesn't fire.
+ </span>
+ </div>
+ </div>
+
+ <form onSubmit={submit} style={{ marginTop: 32, maxWidth: 540, display: 'flex', flexDirection: 'column', gap: 16 }}>
+ {target.kind === 'pro' && (
+ <label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
+ <span style={{ fontSize: 13, fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase', color: 'var(--ink-soft)' }}>
+ NPI (10 digits)
+ </span>
+ <input
+ type="text"
+ required
+ pattern="\d{10}"
+ maxLength={10}
+ value={npi}
+ onChange={e => setNpi(e.target.value.replace(/\D/g, '').slice(0, 10))}
+ placeholder="e.g. 1234567890"
+ style={{ padding: '10px 14px', fontSize: 16, border: '1px solid var(--ink-soft)', borderRadius: 6, background: 'transparent', color: 'var(--ink)' }}
+ />
+ <span style={{ fontSize: 12, color: 'var(--ink-soft)' }}>
+ Auto-approval matches NPI against our copy + checks your email domain against your practice website.
+ </span>
+ </label>
+ )}
+
+ <label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
+ <span style={{ fontSize: 13, fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase', color: 'var(--ink-soft)' }}>
+ CA license # (optional)
+ </span>
+ <input
+ type="text"
+ maxLength={64}
+ value={license}
+ onChange={e => setLicense(e.target.value.slice(0, 64))}
+ placeholder="MD / DO / DC / LAc / RN / etc."
+ style={{ padding: '10px 14px', fontSize: 16, border: '1px solid var(--ink-soft)', borderRadius: 6, background: 'transparent', color: 'var(--ink)' }}
+ />
+ <span style={{ fontSize: 12, color: 'var(--ink-soft)' }}>
+ Speeds up admin review for non-NPI verticals (acupuncture, chiropractic, RCFE, etc.).
+ </span>
+ </label>
+
+ {err && (
+ <p role="alert" style={{ color: '#9b1c1c', background: '#fff5f5', border: '1px solid #fecaca', padding: '10px 14px', borderRadius: 6, fontSize: 14 }}>
+ {err}
+ </p>
+ )}
+
+ <div style={{ display: 'flex', gap: 12, alignItems: 'center', marginTop: 8 }}>
+ <button type="submit" disabled={submitting} style={cardBtn(submitting)}>
+ {submitting ? 'Submitting…' : 'Send verify link →'}
+ </button>
+ <a href="/account" style={{ fontSize: 14, color: 'var(--ink-soft)' }}>Cancel</a>
+ </div>
+
+ <p style={{ marginTop: 8, fontSize: 12, color: 'var(--ink-soft)' }}>
+ By submitting you agree drvouch is non-clinical and that you have the authority to act on behalf of this profile.
+ Fraudulent claims are revoked and the user blocked.
+ </p>
+ </form>
+ </section>
+ );
+}
diff --git a/agents/web-agent/app/account/page.tsx b/agents/web-agent/app/account/page.tsx
new file mode 100644
index 0000000..731ca0f
--- /dev/null
+++ b/agents/web-agent/app/account/page.tsx
@@ -0,0 +1,118 @@
+// /account — login + role + subscription state, all proxied via /api/* to pd-api.
+'use client';
+import { useEffect, useState } from 'react';
+
+type WhoAmI = {
+ user: null | {
+ id: number; email: string; role: string; tier: string;
+ display_name?: string; claimed_professional_id?: number; claimed_organization_id?: number;
+ synthetic?: boolean;
+ };
+};
+
+export default function AccountPage() {
+ const [me, setMe] = useState<WhoAmI | null>(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ fetch('/api/auth/whoami', { credentials: 'include' })
+ .then(r => r.ok ? r.json() : { user: null })
+ .then(setMe)
+ .finally(() => setLoading(false));
+ }, []);
+
+ if (loading) return <section className="section"><h2>Loading…</h2></section>;
+
+ if (!me?.user) {
+ return (
+ <section className="section">
+ <div className="eyebrow">Account</div>
+ <h2>Sign in to drvouch</h2>
+ <p style={{ maxWidth: 540, color: 'var(--ink-soft)' }}>
+ Patients: Google sign-in, free, leave reviews, get personalized matches.
+ Doctors / office managers: claim your practice in 60 seconds (NPI + practice email).
+ </p>
+ <a className="card" href="/api/auth/google?returnTo=/account"
+ style={{ display: 'inline-block', marginTop: 16, padding: '12px 22px', background: 'var(--moss)', color: '#fff', borderRadius: 999, fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase', fontSize: 14 }}>
+ Continue with Google →
+ </a>
+ <p style={{ marginTop: 22, color: 'var(--ink-soft)', fontSize: 13 }}>
+ By signing in, you agree drvouch is non-clinical: reviews and messages are about service / scheduling / billing — not medical advice.
+ </p>
+ </section>
+ );
+ }
+
+ const u = me.user;
+ return (
+ <section className="section">
+ <div className="eyebrow">Account</div>
+ <h2>Welcome, {u.display_name || u.email}</h2>
+ <p style={{ color: 'var(--ink-soft)' }}>
+ {u.synthetic ? 'Loopback synthetic-admin (Steve).' : `Signed in as ${u.email}.`}
+ </p>
+
+ <div className="card-grid" style={{ marginTop: 32 }}>
+ <div className="card">
+ <span className="badge">Role</span>
+ <span className="name">{u.role}</span>
+ <span className="meta">
+ {u.role === 'patient' && 'Browse, leave reviews, suppress comments on your own page.'}
+ {u.role === 'doctor' && 'Respond to reviews, edit page, DM patients (paid tier).'}
+ {u.role === 'admin' && 'You see everything. Approve claims at /admin/doctor-claims.'}
+ {u.role === 'guest' && 'Read-only.'}
+ </span>
+ </div>
+ <div className="card">
+ <span className="badge">Tier</span>
+ <span className="name">{u.tier === 'paid' ? '★ Paid' : 'Free'}</span>
+ <span className="meta">
+ {u.tier === 'paid'
+ ? 'DMs + threads + responses unlocked.'
+ : 'Free reads. Upgrade to participate.'}
+ </span>
+ {u.tier !== 'paid' && (
+ <SubscribeButton role={u.role} />
+ )}
+ </div>
+ {(u.claimed_professional_id || u.claimed_organization_id) && (
+ <div className="card">
+ <span className="badge">Claimed</span>
+ <span className="name">
+ {u.claimed_professional_id ? `Professional #${u.claimed_professional_id}` :
+ u.claimed_organization_id ? `Organization #${u.claimed_organization_id}` : ''}
+ </span>
+ <span className="meta">Edit your page content from this dashboard.</span>
+ </div>
+ )}
+ </div>
+
+ <form action="/api/auth/logout" method="POST" style={{ marginTop: 32 }}>
+ <button type="submit" style={{ background: 'transparent', border: '1px solid var(--ink)', color: 'var(--ink)', padding: '10px 18px', borderRadius: 999, fontWeight: 600, fontSize: 13, letterSpacing: '0.04em', textTransform: 'uppercase', cursor: 'pointer' }}>
+ Sign out
+ </button>
+ </form>
+ </section>
+ );
+}
+
+function SubscribeButton({ role }: { role: string }) {
+ const plan = role === 'doctor' ? 'doctor_pro_monthly' : 'patient_plus_monthly';
+ const onClick = async () => {
+ const r = await fetch('/api/subscriptions/checkout', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ plan }),
+ });
+ const j = await r.json();
+ if (j.url) window.location.href = j.url;
+ else alert(j.error || 'Checkout failed');
+ };
+ return (
+ <button onClick={onClick}
+ style={{ marginTop: 12, background: 'var(--moss)', color: '#fff', border: 0, padding: '10px 18px', borderRadius: 999, fontWeight: 600, fontSize: 13, letterSpacing: '0.04em', textTransform: 'uppercase', cursor: 'pointer', alignSelf: 'flex-start' }}>
+ Upgrade →
+ </button>
+ );
+}
diff --git a/agents/web-agent/app/community/page.tsx b/agents/web-agent/app/community/page.tsx
new file mode 100644
index 0000000..6a40e04
--- /dev/null
+++ b/agents/web-agent/app/community/page.tsx
@@ -0,0 +1,38 @@
+// /community — patient + doctor threads. Phase 4 scaffold; placeholder
+// content until threads/messages tables ship.
+
+export default function CommunityPage() {
+ const placeholderThreads = [
+ { title: 'Best primary care in Mid-City under 30 min wait?', city: 'Mid-City LA', kind: 'Recommendations', replies: 14 },
+ { title: 'Anyone with experience at Cedars-Sinai cardiology?', city: 'West Hollywood', kind: 'Specialty', replies: 22 },
+ { title: 'Pediatric dentists open weekends in the Valley', city: 'Sherman Oaks', kind: 'Pediatrics', replies: 9 },
+ { title: 'In-network OB/GYN with Anthem Blue Cross', city: 'Westside', kind: 'Insurance', replies: 31 },
+ { title: 'Geriatric care — assisted vs at-home (Pasadena)', city: 'Pasadena', kind: 'Eldercare', replies: 17 },
+ { title: 'Where do I claim my practice page?', city: '—', kind: 'Practice owners', replies: 4 },
+ ];
+
+ return (
+ <section className="section">
+ <div className="eyebrow">Community</div>
+ <h2>What Angelenos are asking each other</h2>
+ <p style={{ maxWidth: 580, color: 'var(--ink-soft)' }}>
+ Patients comparing notes. Doctors answering. Office managers explaining insurance.
+ Free to read; subscribers can post and DM.
+ </p>
+
+ <div className="card-grid" style={{ marginTop: 32 }}>
+ {placeholderThreads.map((t, i) => (
+ <a key={i} className="card" href="#">
+ <span className="badge">{t.kind}</span>
+ <span className="name">{t.title}</span>
+ <span className="meta">{t.city} · {t.replies} replies</span>
+ </a>
+ ))}
+ </div>
+
+ <p style={{ color: 'var(--ink-soft)', fontStyle: 'italic', marginTop: 32, textAlign: 'center' }}>
+ Threads + DMs ship in Phase 4 (real-time via socket.io).
+ </p>
+ </section>
+ );
+}
diff --git a/agents/web-agent/app/doctors/[slug]/page.tsx b/agents/web-agent/app/doctors/[slug]/page.tsx
new file mode 100644
index 0000000..4f8b7b5
--- /dev/null
+++ b/agents/web-agent/app/doctors/[slug]/page.tsx
@@ -0,0 +1,93 @@
+// /doctors/[slug] — individual professional public profile.
+
+const PD_API = process.env.PD_API_BASE || 'http://127.0.0.1:9874';
+
+async function getPro(id: string) {
+ try {
+ const r = await fetch(`${PD_API}/professionals?limit=1&min_confidence=0`, { next: { revalidate: 300 } });
+ // /professionals is filter-shaped, not single-id. Single-fetch is /professionals/:npi.
+ // We'll route by NPI in the URL slug; if it's a numeric id the pd-api detail route
+ // doesn't currently support single-by-id — fall back to the search endpoint.
+ return null;
+ } catch { return null; }
+}
+
+async function getProByNpi(npi: string) {
+ try {
+ const r = await fetch(`${PD_API}/professionals/${npi}`, { next: { revalidate: 300 } });
+ return r.ok ? await r.json() : null;
+ } catch { return null; }
+}
+
+export default async function DoctorPage({ params }: { params: { slug: string } }) {
+ const slug = String(params.slug || '');
+ // Slug is the NPI (10 digits) or the pro id — we support both.
+ const isNpi = /^\d{10}$/.test(slug);
+
+ let pro: any = null;
+ if (isNpi) pro = await getProByNpi(slug);
+
+ if (!pro) {
+ return (
+ <section className="section">
+ <h2>Doctor profile coming soon</h2>
+ <p style={{ color: 'var(--ink-soft)' }}>
+ Public physician profiles ship in Phase 2. For now, search by name to find a practice;
+ NPI-based slugs will resolve once we wire the lookup.
+ </p>
+ <a href="/search" className="card" style={{ display: 'inline-block', padding: '12px 22px', background: 'var(--moss)', color: '#fff', borderRadius: 999, fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase', fontSize: 14 }}>
+ Back to search →
+ </a>
+ </section>
+ );
+ }
+
+ return (
+ <section className="section" style={{ paddingTop: 56 }}>
+ <div className="eyebrow">Professional</div>
+ <h2>{titleCase(pro.full_name)}{pro.title ? `, ${pro.title}` : ''}</h2>
+ <p style={{ color: 'var(--ink-soft)', fontSize: 17 }}>
+ {pro.primary_specialty || 'Specialty TBD'}
+ {pro.medical_school ? ` · ${pro.medical_school}` : ''}
+ {pro.graduation_year ? ` · Class of ${pro.graduation_year}` : ''}
+ </p>
+
+ <div className="card-grid" style={{ marginTop: 32 }}>
+ <div className="card">
+ <span className="badge">License</span>
+ <span className="name">{pro.license_status || 'Active'}</span>
+ <span className="meta">CA License #{pro.license_number || '—'} · NPI {pro.npi_number}</span>
+ </div>
+ <div className="card">
+ <span className="badge">Specialties</span>
+ <span className="name">{pro.specialties?.length || 1} on file</span>
+ <span className="meta">{(pro.specialties || []).slice(0, 4).join(' · ') || pro.primary_specialty || ''}</span>
+ </div>
+ <div className="card">
+ <span className="badge">Profile</span>
+ <span className="name">{pro.claim_status === 'verified' ? '✓ Verified' : 'Unclaimed'}</span>
+ <span className="meta">{pro.claim_status === 'verified' ? 'Doctor manages this page' : 'Auto-generated from public records'}</span>
+ </div>
+ </div>
+
+ <section style={{ marginTop: 56 }}>
+ <div className="eyebrow">Reviews</div>
+ <h2 style={{ marginTop: 14 }}>Patient ratings</h2>
+ <p style={{ color: 'var(--ink-soft)' }}>Phase 3 — reviews + ratings UI coming.</p>
+ </section>
+
+ <section style={{ marginTop: 56, borderTop: '1px solid var(--line)', paddingTop: 56 }}>
+ <h2>Is this you?</h2>
+ <p style={{ maxWidth: 580, color: 'var(--ink-soft)' }}>
+ Claim this profile to respond to reviews, message patients, and edit page content.
+ Verify with your NPI + practice domain email.
+ </p>
+ <a href={`/account/claim?npi=${pro.npi_number}`} className="card" style={{ display: 'inline-block', padding: '12px 22px', textAlign: 'center', background: 'var(--moss)', color: '#fff', borderRadius: 999, fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase', fontSize: 14 }}>
+ Claim this profile →
+ </a>
+ </section>
+ </section>
+ );
+}
+
+function titleCase(s: string) { return String(s || '').toLowerCase().replace(/\b\w/g, c => c.toUpperCase()); }
diff --git a/agents/web-agent/app/globals.css b/agents/web-agent/app/globals.css
new file mode 100644
index 0000000..4aac31b
--- /dev/null
+++ b/agents/web-agent/app/globals.css
@@ -0,0 +1,177 @@
+/* drvouch public-facing site — grid-first, cream-on-moss, modern editorial. */
+
+:root {
+ --cream: #fbf7f0;
+ --ink: #1c1f1c;
+ --ink-soft: #4a504a;
+ --line: #e6dfd1;
+ --moss: #3f5f4a;
+ --moss-deep: #28412f;
+ --gold: #b48a3a;
+ --rose: #c97e74;
+ --header-bg: rgba(251,247,240,0.94);
+ --surface: #ffffff;
+ --maxw: 1200px;
+}
+[data-theme="dark"] {
+ --cream: #14181a;
+ --ink: #ece7d9;
+ --ink-soft: #9ca39a;
+ --line: #2a2f2a;
+ --moss: #6a8e7a;
+ --moss-deep: #8aae9c;
+ --gold: #d2a958;
+ --rose: #e09790;
+ --header-bg: rgba(20,24,26,0.94);
+ --surface: #1c2123;
+}
+html { color-scheme: light dark; background: var(--cream); }
+html[data-theme="dark"] { color-scheme: dark; }
+
+*, *::before, *::after { box-sizing: border-box; }
+html, body { margin: 0; padding: 0; }
+body {
+ font: 16px/1.6 ui-serif, "Iowan Old Style", "Apple Garamond", Garamond, "Times New Roman", serif;
+ color: var(--ink);
+ background: var(--cream);
+ -webkit-font-smoothing: antialiased;
+}
+a { color: var(--moss-deep); text-decoration: none; }
+a:hover { text-decoration: underline; }
+
+.site-header {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 22px 32px;
+ border-bottom: 1px solid var(--line);
+ background: var(--header-bg);
+ backdrop-filter: blur(10px);
+ position: sticky; top: 0; z-index: 50;
+}
+.theme-toggle {
+ display: inline-flex; align-items: center; justify-content: center;
+ width: 34px; height: 34px; padding: 0; margin-left: 8px;
+ background: transparent; border: 1px solid var(--line); border-radius: 50%;
+ color: var(--ink-soft); cursor: pointer;
+ transition: color .15s, border-color .15s, background .15s;
+}
+.theme-toggle:hover { color: var(--ink); border-color: var(--ink); }
+.theme-toggle:focus-visible { outline: 2px solid var(--moss); outline-offset: 2px; }
+.theme-toggle .icon-sun { display: none; }
+.theme-toggle .icon-moon { display: inline-block; }
+[data-theme="dark"] .theme-toggle .icon-sun { display: inline-block; }
+[data-theme="dark"] .theme-toggle .icon-moon { display: none; }
+.brand {
+ font: 600 22px/1 ui-serif, serif;
+ letter-spacing: -0.02em;
+ color: var(--ink);
+}
+.brand-dot { color: var(--moss); }
+.primary-nav { display: flex; gap: 26px; font: 14px/1 ui-sans-serif, system-ui, sans-serif; letter-spacing: 0.02em; }
+.primary-nav a { color: var(--ink); }
+
+main { min-height: calc(100vh - 220px); }
+.site-footer {
+ padding: 40px 32px;
+ border-top: 1px solid var(--line);
+ display: flex; align-items: center; justify-content: space-between;
+ font: 12px/1.4 ui-sans-serif, system-ui, sans-serif;
+ color: var(--ink-soft); flex-wrap: wrap; gap: 14px;
+}
+.site-footer .muted { color: var(--ink-soft); opacity: .7; }
+
+/* ──────────────── Hero (homepage) ──────────────── */
+
+.hero { padding: 72px 32px 56px; max-width: var(--maxw); margin: 0 auto; text-align: center; }
+.hero h1 {
+ font-size: clamp(36px, 5vw, 64px);
+ line-height: 1.05;
+ letter-spacing: -0.02em;
+ font-weight: 500;
+ margin: 0 0 20px;
+}
+.hero p.lead {
+ font-size: 19px; line-height: 1.55; color: var(--ink-soft);
+ margin: 0 auto 32px; max-width: 580px;
+}
+
+/* ──────────────── Search bar ──────────────── */
+
+.search-shell {
+ max-width: 720px; margin: 0 auto 48px;
+ display: flex; gap: 8px; padding: 6px;
+ background: var(--surface);
+ border: 1px solid var(--line); border-radius: 999px;
+ box-shadow: 0 8px 24px -16px rgba(28,31,28,0.18);
+}
+.search-shell input {
+ flex: 1; border: 0; padding: 14px 22px;
+ font: 16px/1.4 ui-sans-serif, system-ui, sans-serif;
+ background: transparent; outline: none;
+}
+.search-shell button {
+ background: var(--moss); color: #fff; border: 0;
+ padding: 14px 26px; border-radius: 999px;
+ font: 600 14px/1 ui-sans-serif, system-ui, sans-serif;
+ letter-spacing: 0.05em; text-transform: uppercase; cursor: pointer;
+}
+.search-shell button:hover { background: var(--moss-deep); }
+
+/* ──────────────── Stat ribbon ──────────────── */
+
+.stat-ribbon {
+ background: var(--moss); color: #fbf7f0;
+ padding: 28px 32px;
+ display: grid; grid-template-columns: repeat(4, 1fr); gap: 32px;
+ max-width: var(--maxw); margin: 0 auto; border-radius: 12px;
+}
+.stat-ribbon .stat b { display: block; font: 500 28px/1 ui-serif, serif; margin-bottom: 6px; color: #fff; }
+.stat-ribbon .stat { font: 14px/1.45 ui-sans-serif, system-ui, sans-serif; letter-spacing: 0.02em; }
+
+/* ──────────────── Card grid (the look Steve likes) ──────────────── */
+
+.section { max-width: var(--maxw); margin: 0 auto; padding: 64px 32px; }
+.section h2 { font-size: clamp(26px, 3vw, 36px); margin: 0 0 28px; font-weight: 500; letter-spacing: -0.01em; }
+.eyebrow { color: var(--gold); font: 600 12px/1 ui-sans-serif, system-ui, sans-serif; letter-spacing: 0.18em; text-transform: uppercase; margin-bottom: 14px; }
+
+.card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: 18px;
+}
+.card {
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 22px;
+ transition: transform .12s ease, box-shadow .12s ease;
+ display: flex; flex-direction: column; gap: 8px;
+}
+.card:hover { transform: translateY(-2px); box-shadow: 0 12px 30px -18px rgba(28,31,28,0.18); }
+.card .name { font: 600 17px/1.25 ui-serif, serif; letter-spacing: -0.01em; }
+.card .meta { font: 13px/1.45 ui-sans-serif, system-ui, sans-serif; color: var(--ink-soft); }
+.card .badge {
+ display: inline-block; padding: 3px 9px; border-radius: 999px;
+ background: rgba(63,95,74,0.10); color: var(--moss-deep);
+ font: 600 11px/1.2 ui-sans-serif, system-ui, sans-serif;
+ letter-spacing: 0.04em; text-transform: uppercase;
+ align-self: flex-start;
+}
+.card .stars { color: var(--gold); font-size: 14px; letter-spacing: 0.05em; }
+
+/* dense grid variant (search results, community) */
+.dense-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 12px;
+}
+.dense-grid .card { padding: 16px; gap: 4px; }
+.dense-grid .card .name { font-size: 15px; }
+.dense-grid .card .meta { font-size: 12px; }
+
+@media (max-width: 720px) {
+ .stat-ribbon { grid-template-columns: repeat(2, 1fr); gap: 22px; }
+ .site-header { padding: 16px 22px; }
+ .primary-nav { gap: 16px; font-size: 13px; }
+ .hero { padding: 48px 22px 32px; }
+ .section { padding: 40px 22px; }
+}
diff --git a/agents/web-agent/app/layout.tsx b/agents/web-agent/app/layout.tsx
new file mode 100644
index 0000000..a11ea43
--- /dev/null
+++ b/agents/web-agent/app/layout.tsx
@@ -0,0 +1,41 @@
+import type { Metadata } from 'next';
+import './globals.css';
+
+export const metadata: Metadata = {
+ title: { default: 'drvouch — find a doctor your community vouches for', template: '%s · drvouch' },
+ description: 'Patient-vouched LA doctors, dentists, optometrists, and care providers. Real reviews, real ratings, no algorithm games.',
+ openGraph: { type: 'website', siteName: 'drvouch' },
+};
+
+const themeBootstrap = `(function(){try{var t=localStorage.getItem('theme');if(!t){t=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}document.documentElement.setAttribute('data-theme',t);}catch(e){}})();`;
+const themeToggleScript = `(function(){var b=document.getElementById('theme-toggle');if(!b)return;function s(){var t=document.documentElement.getAttribute('data-theme');b.setAttribute('aria-pressed',t==='dark'?'true':'false');}s();b.addEventListener('click',function(){var c=document.documentElement.getAttribute('data-theme')||'light';var n=c==='dark'?'light':'dark';document.documentElement.setAttribute('data-theme',n);try{localStorage.setItem('theme',n);}catch(e){}s();});})();`;
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+ <html lang="en">
+ <head>
+ <script dangerouslySetInnerHTML={{ __html: themeBootstrap }} />
+ </head>
+ <body>
+ <header className="site-header">
+ <a href="/" className="brand">drvouch<span className="brand-dot">.</span></a>
+ <nav className="primary-nav">
+ <a href="/search">Search</a>
+ <a href="/community">Community</a>
+ <a href="/account">Account</a>
+ <button type="button" id="theme-toggle" className="theme-toggle" aria-label="Toggle dark mode" aria-pressed="false" title="Toggle dark / light">
+ <svg className="icon-sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
+ <svg className="icon-moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z"/></svg>
+ </button>
+ </nav>
+ </header>
+ <main>{children}</main>
+ <footer className="site-footer">
+ <span>© drvouch — patient-vouched LA healthcare</span>
+ <span className="muted">Not medical advice. For clinical questions, call the office.</span>
+ </footer>
+ <script dangerouslySetInnerHTML={{ __html: themeToggleScript }} />
+ </body>
+ </html>
+ );
+}
diff --git a/agents/web-agent/app/mockups/page.tsx b/agents/web-agent/app/mockups/page.tsx
new file mode 100644
index 0000000..70c699d
--- /dev/null
+++ b/agents/web-agent/app/mockups/page.tsx
@@ -0,0 +1,97 @@
+// /mockups — admin gallery of generated per-org website mockups.
+// Pulls _manifest.json from data/mockups/, renders a grid of iframe thumbnails.
+
+import fs from 'node:fs';
+import path from 'node:path';
+
+const PD_API = process.env.PD_API_BASE || 'http://127.0.0.1:9874';
+const MOCKUPS_DIR = path.resolve(process.cwd(), '../../data/mockups');
+
+type ManifestEntry = {
+ id: number;
+ name: string;
+ status: 'ok' | 'cached' | 'fail';
+ ms?: number;
+ kb?: number;
+ template?: string;
+};
+
+async function getManifest(): Promise<ManifestEntry[]> {
+ try {
+ const raw = fs.readFileSync(path.join(MOCKUPS_DIR, '_manifest.json'), 'utf8');
+ const j = JSON.parse(raw);
+ return j.results || [];
+ } catch { return []; }
+}
+
+async function getOrgs(ids: number[]) {
+ if (!ids.length) return new Map<number, any>();
+ // Hit pd-api for org details (fast — small set)
+ const out = new Map<number, any>();
+ for (const id of ids.slice(0, 60)) {
+ try {
+ const r = await fetch(`${PD_API}/organizations/${id}`, { next: { revalidate: 600 } });
+ if (r.ok) out.set(id, await r.json());
+ } catch {}
+ }
+ return out;
+}
+
+export default async function MockupsPage() {
+ const manifest = await getManifest();
+ const ids = manifest.filter(r => r.status === 'ok' || r.status === 'cached').map(r => r.id);
+ const orgs = await getOrgs(ids);
+
+ return (
+ <section className="section" style={{ maxWidth: 'none' }}>
+ <div className="eyebrow">Admin · Generated mockups</div>
+ <h2>Top-20 bespoke website mockups</h2>
+ <p style={{ color: 'var(--ink-soft)', maxWidth: 720 }}>
+ Per-org single-page mockups generated by gemma3:12b on Mac Studio 2 using template-rotation
+ (8 top-rated healthcare practice sites) + healthcare-modern aesthetic + SEO-tuned JSON-LD.
+ Each mockup includes a floating AI-chat widget for visitors to ask about hours, services, location.
+ </p>
+
+ {manifest.length === 0 ? (
+ <div className="card" style={{ marginTop: 32, padding: 32, textAlign: 'center' }}>
+ <p>No mockups yet. Run <code>node scripts/generate-mockups.js</code>.</p>
+ </div>
+ ) : (
+ <div className="card-grid" style={{ marginTop: 32, gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))' }}>
+ {manifest.filter(r => r.status === 'ok' || r.status === 'cached').map((r) => {
+ const o = orgs.get(r.id);
+ const url = `${PD_API}/mockups/${r.id}.html`;
+ return (
+ <a key={r.id} className="card" href={url} target="_blank" rel="noopener" style={{ padding: 0, overflow: 'hidden' }}>
+ <iframe
+ src={url}
+ style={{ width: '100%', aspectRatio: '5/4', border: 0, background: '#fbf7f0', pointerEvents: 'none' }}
+ loading="lazy"
+ scrolling="no"
+ title={`Mockup for ${r.name}`}
+ />
+ <div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 4 }}>
+ <span className="badge">{r.template || 'tpl'}</span>
+ <span className="name">{titleCase(r.name)}</span>
+ <span className="meta">
+ {o ? `${titleCase(o.city || '')} · ${o.type?.replace(/_/g, ' ')}` : `org #${r.id}`}
+ {r.kb ? ` · ${r.kb}KB` : ''}{r.ms ? ` · ${(r.ms / 1000).toFixed(1)}s gen` : ''}
+ </span>
+ </div>
+ </a>
+ );
+ })}
+ </div>
+ )}
+
+ {manifest.some(r => r.status === 'fail') && (
+ <div style={{ marginTop: 32, padding: 16, background: '#f5e6d3', border: '1px solid var(--gold)', borderRadius: 8 }}>
+ <strong>Failed:</strong>{' '}
+ {manifest.filter(r => r.status === 'fail').map(r => r.name).join(', ')}
+ </div>
+ )}
+ </section>
+ );
+}
+
+function titleCase(s: string) { return String(s || '').toLowerCase().replace(/\b\w/g, c => c.toUpperCase()); }
diff --git a/agents/web-agent/app/orgs/[slug]/page.tsx b/agents/web-agent/app/orgs/[slug]/page.tsx
new file mode 100644
index 0000000..ee33175
--- /dev/null
+++ b/agents/web-agent/app/orgs/[slug]/page.tsx
@@ -0,0 +1,58 @@
+// /orgs/[slug] — public org profile. Reuses the existing pd-preview render
+// for now (consistency with admin pitch flow); reviews + community appended.
+
+const PD_API = process.env.PD_API_BASE || 'http://127.0.0.1:9874';
+const PD_PREVIEW = process.env.PD_PREVIEW_BASE || 'http://127.0.0.1:9875';
+
+export default async function OrgPage({ params }: { params: { slug: string } }) {
+ const id = String(params.slug || '').replace(/[^0-9]/g, '');
+ if (!id) {
+ return <section className="section"><h2>Not found</h2></section>;
+ }
+
+ // Render the existing pd-preview HTML inline (same template as Steve's pitch).
+ // Reviews + community discussion are appended below.
+ const previewHtml = await fetch(`${PD_PREVIEW}/preview/${id}`, { next: { revalidate: 600 } })
+ .then(r => r.ok ? r.text() : null)
+ .catch(() => null);
+
+ const orgJson = await fetch(`${PD_API}/organizations/${id}`).then(r => r.ok ? r.json() : null).catch(() => null);
+
+ if (!orgJson || !previewHtml) {
+ return <section className="section"><h2>Not found</h2></section>;
+ }
+
+ return (
+ <>
+ {/* Inline-iframe the preview for safe isolation; alternative is dangerouslySetInnerHTML */}
+ <iframe
+ src={`${PD_PREVIEW}/preview/${id}`}
+ style={{ width: '100%', minHeight: '900px', border: 0 }}
+ title={`${orgJson.name} preview`}
+ />
+
+ <section className="section">
+ <div className="eyebrow">Reviews</div>
+ <h2>What patients say</h2>
+ <p style={{ color: 'var(--ink-soft)' }}>
+ Reviews go live in Phase 3. Initial scores will seed from public Reddit + Yelp + Healthgrades signals;
+ patient subscribers can verify and add their own.
+ </p>
+ </section>
+
+ <section className="section" style={{ borderTop: '1px solid var(--line)' }}>
+ <div className="eyebrow">For this practice</div>
+ <h2>Are you the owner?</h2>
+ <p style={{ maxWidth: 580, color: 'var(--ink-soft)' }}>
+ Claim {titleCase(orgJson.name)} to respond to reviews, edit page content, and DM patients directly.
+ Verification: NPI + practice domain email.
+ </p>
+ <a href={`/account/claim?org=${id}`} className="card" style={{ display: 'inline-block', padding: '12px 22px', textAlign: 'center', background: 'var(--moss)', color: '#fff', borderRadius: 999, fontWeight: 600, letterSpacing: '0.04em', textTransform: 'uppercase', fontSize: 14 }}>
+ Claim this practice →
+ </a>
+ </section>
+ </>
+ );
+}
+
+function titleCase(s: string) { return String(s || '').toLowerCase().replace(/\b\w/g, c => c.toUpperCase()); }
diff --git a/agents/web-agent/app/page.tsx b/agents/web-agent/app/page.tsx
new file mode 100644
index 0000000..8e462de
--- /dev/null
+++ b/agents/web-agent/app/page.tsx
@@ -0,0 +1,90 @@
+// Homepage — search hero + ratio ribbon + grid of recently-vouched orgs.
+// Hits pd-api on loopback for the live data.
+
+const PD_API = process.env.PD_API_BASE || 'http://127.0.0.1:9874';
+
+async function fetchJson(path: string) {
+ try {
+ const r = await fetch(`${PD_API}${path}`, { next: { revalidate: 300 }, headers: { Accept: 'application/json' } });
+ if (!r.ok) return null;
+ return await r.json();
+ } catch { return null; }
+}
+
+async function getCounts() {
+ return fetchJson('/health');
+}
+
+async function getRecentOrgs() {
+ return fetchJson('/organizations?has_email=true&limit=12&sort=score');
+}
+
+export default async function HomePage() {
+ const [health, orgs] = await Promise.all([getCounts(), getRecentOrgs()]);
+ const c = health?.counts || {};
+ const orgRows: any[] = orgs?.rows || [];
+
+ return (
+ <>
+ <section className="hero">
+ <div className="eyebrow">Patient-vouched LA healthcare</div>
+ <h1>Find a doctor your community vouches for.</h1>
+ <p className="lead">
+ Real reviews from Angelenos. Real ratings on service, price, and quality.
+ No algorithm games. {Number(c.organizations || 0).toLocaleString()} clinics, hospitals, and practices.
+ </p>
+ <form className="search-shell" action="/search" method="GET">
+ <input type="text" name="q" placeholder="Search doctor, specialty, or city" autoFocus />
+ <button type="submit">Search</button>
+ </form>
+
+ <div className="stat-ribbon">
+ <div className="stat"><b>{Number(c.professionals || 0).toLocaleString()}</b>Professionals listed</div>
+ <div className="stat"><b>{Number(c.organizations || 0).toLocaleString()}</b>Practices & facilities</div>
+ <div className="stat"><b>{Number(c.specialties || 0).toLocaleString()}</b>Specialties covered</div>
+ <div className="stat"><b>LA</b>County, every ZIP</div>
+ </div>
+ </section>
+
+ <section className="section">
+ <div className="eyebrow">Recently active</div>
+ <h2>Practices people are talking about</h2>
+ {orgRows.length === 0 ? (
+ <p style={{ color: 'var(--ink-soft)' }}>No data yet — pd-api may be still warming up.</p>
+ ) : (
+ <div className="card-grid">
+ {orgRows.map((o) => (
+ <a key={o.id} href={`/orgs/${o.id}`} className="card">
+ <span className="badge">{titleCase(o.type)}</span>
+ <span className="name">{titleCase(o.name)}</span>
+ <span className="meta">
+ {[titleCase(o.city || ''), o.zip].filter(Boolean).join(' · ')}
+ {o.has_email ? ' · ✉ on file' : ''}
+ </span>
+ <span className="stars">★★★★☆</span>
+ </a>
+ ))}
+ </div>
+ )}
+ </section>
+
+ <section className="section" style={{ borderTop: '1px solid var(--line)' }}>
+ <div className="eyebrow">For practitioners</div>
+ <h2>Claim your practice. Respond to patients. Edit your page.</h2>
+ <p style={{ maxWidth: 580, color: 'var(--ink-soft)' }}>
+ drvouch automatically builds a draft profile for every licensed practice in LA.
+ Doctors and office managers can claim theirs in 60 seconds (NPI + practice email)
+ to respond to reviews, message patients directly, and customize the page content.
+ </p>
+ <div style={{ display: 'flex', gap: 12, marginTop: 18, flexWrap: 'wrap' }}>
+ <a href="/account/claim" className="btn-primary" style={{ background: 'var(--moss)', color: '#fff', padding: '12px 22px', borderRadius: 999, fontWeight: 600, fontSize: 14, letterSpacing: '0.04em', textTransform: 'uppercase' }}>Claim a practice</a>
+ <a href="/community" style={{ padding: '12px 22px', borderBottom: '1px solid var(--ink)', fontWeight: 600, fontSize: 14, letterSpacing: '0.04em', textTransform: 'uppercase' }}>Community →</a>
+ </div>
+ </section>
+ </>
+ );
+}
+
+function titleCase(s: string) {
+ return String(s || '').toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
+}
diff --git a/agents/web-agent/app/search/page.tsx b/agents/web-agent/app/search/page.tsx
new file mode 100644
index 0000000..9bdfa12
--- /dev/null
+++ b/agents/web-agent/app/search/page.tsx
@@ -0,0 +1,56 @@
+// /search?q=... — search across professionals + organizations via pd-api /search.
+const PD_API = process.env.PD_API_BASE || 'http://127.0.0.1:9874';
+
+async function search(q: string) {
+ if (!q || q.length < 2) return { count: 0, rows: [] };
+ try {
+ const r = await fetch(`${PD_API}/search?q=${encodeURIComponent(q)}&limit=60`, { next: { revalidate: 60 } });
+ if (!r.ok) return { count: 0, rows: [] };
+ return await r.json();
+ } catch { return { count: 0, rows: [] }; }
+}
+
+export default async function SearchPage({ searchParams }: { searchParams: { q?: string } }) {
+ const q = String(searchParams?.q || '');
+ const out = await search(q);
+ const rows: any[] = out.rows || [];
+
+ return (
+ <section className="section">
+ <div className="eyebrow">Search</div>
+ <h2>{q ? `Results for "${q}"` : 'Search'}</h2>
+
+ <form action="/search" method="GET" className="search-shell" style={{ marginBottom: 32, marginLeft: 0, marginRight: 0 }}>
+ <input type="text" name="q" placeholder="Doctor, specialty, city, or practice name" defaultValue={q} />
+ <button type="submit">Search</button>
+ </form>
+
+ {q.length < 2 ? (
+ <p style={{ color: 'var(--ink-soft)' }}>Type a name, specialty, or ZIP to search.</p>
+ ) : rows.length === 0 ? (
+ <p style={{ color: 'var(--ink-soft)' }}>No matches for "{q}".</p>
+ ) : (
+ <>
+ <p style={{ color: 'var(--ink-soft)', marginTop: 0, marginBottom: 22 }}>{out.count} results</p>
+ <div className="dense-grid">
+ {rows.map((r) => {
+ const isPro = r.kind === 'professional';
+ const href = isPro ? `/doctors/${r.id}` : `/orgs/${r.id}`;
+ return (
+ <a key={`${r.kind}-${r.id}`} className="card" href={href}>
+ <span className="badge">{isPro ? 'Doctor' : 'Practice'}</span>
+ <span className="name">{titleCase(r.name)}</span>
+ <span className="meta">{r.detail || ''}</span>
+ </a>
+ );
+ })}
+ </div>
+ </>
+ )}
+ </section>
+ );
+}
+
+function titleCase(s: string) {
+ return String(s || '').toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
+}
diff --git a/agents/web-agent/middleware.ts b/agents/web-agent/middleware.ts
new file mode 100644
index 0000000..1a5e8e9
--- /dev/null
+++ b/agents/web-agent/middleware.ts
@@ -0,0 +1,23 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+const BASIC_AUTH = process.env.BASIC_AUTH || 'admin:DWSecure2024!';
+const EXPECTED = 'Basic ' + Buffer.from(BASIC_AUTH).toString('base64');
+
+export function middleware(req: NextRequest) {
+ const url = req.nextUrl.pathname;
+ if (url === '/health' || url === '/healthz' || url.startsWith('/_next/') || url === '/favicon.ico') {
+ return NextResponse.next();
+ }
+ const authHeader = req.headers.get('authorization');
+ if (authHeader === EXPECTED) {
+ return NextResponse.next();
+ }
+ return new NextResponse('auth required', {
+ status: 401,
+ headers: { 'WWW-Authenticate': 'Basic realm="mac2-pm2"' },
+ });
+}
+
+export const config = {
+ matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
+};
diff --git a/agents/web-agent/next-env.d.ts b/agents/web-agent/next-env.d.ts
new file mode 100644
index 0000000..40c3d68
--- /dev/null
+++ b/agents/web-agent/next-env.d.ts
@@ -0,0 +1,5 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
diff --git a/agents/web-agent/next.config.js b/agents/web-agent/next.config.js
new file mode 100644
index 0000000..18dfe59
--- /dev/null
+++ b/agents/web-agent/next.config.js
@@ -0,0 +1,12 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ reactStrictMode: true,
+ // Proxy /api/* through to pd-api on loopback. In prod (Kamatera), pd-api
+ // is on the same box at 127.0.0.1:9874. Override via PD_API_BASE.
+ async rewrites() {
+ return [
+ { source: '/api/:path*', destination: (process.env.PD_API_BASE || 'http://127.0.0.1:9874') + '/:path*' },
+ ];
+ },
+};
+module.exports = nextConfig;
diff --git a/agents/web-agent/package-lock.json b/agents/web-agent/package-lock.json
new file mode 100644
index 0000000..ed50b50
--- /dev/null
+++ b/agents/web-agent/package-lock.json
@@ -0,0 +1,502 @@
+{
+ "name": "pd-web",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "pd-web",
+ "version": "0.1.0",
+ "dependencies": {
+ "next": "14.2.18",
+ "react": "18.3.1",
+ "react-dom": "18.3.1"
+ },
+ "devDependencies": {
+ "@types/node": "20.16.10",
+ "@types/react": "18.3.12",
+ "@types/react-dom": "18.3.1",
+ "typescript": "5.6.3"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "14.2.18",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.18.tgz",
+ "integrity": "sha512-2vWLOUwIPgoqMJKG6dt35fVXVhgM09tw4tK3/Q34GFXDrfiHlG7iS33VA4ggnjWxjiz9KV5xzfsQzJX6vGAekA==",
+ "license": "MIT"
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "14.2.18",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.18.tgz",
+ "integrity": "sha512-tOBlDHCjGdyLf0ube/rDUs6VtwNOajaWV+5FV/ajPgrvHeisllEdymY/oDgv2cx561+gJksfMUtqf8crug7sbA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "14.2.18",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.18.tgz",
+ "integrity": "sha512-uJCEjutt5VeJ30jjrHV1VIHCsbMYnEqytQgvREx+DjURd/fmKy15NaVK4aR/u98S1LGTnjq35lRTnRyygglxoA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "14.2.18",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.18.tgz",
+ "integrity": "sha512-IL6rU8vnBB+BAm6YSWZewc+qvdL1EaA+VhLQ6tlUc0xp+kkdxQrVqAnh8Zek1ccKHlTDFRyAft0e60gteYmQ4A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "14.2.18",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.18.tgz",
+ "integrity": "sha512-RCaENbIZqKKqTlL8KNd+AZV/yAdCsovblOpYFp0OJ7ZxgLNbV5w23CUU1G5On+0fgafrsGcW+GdMKdFjaRwyYA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "14.2.18",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.18.tgz",
+ "integrity": "sha512-3kmv8DlyhPRCEBM1Vavn8NjyXtMeQ49ID0Olr/Sut7pgzaQTo4h01S7Z8YNE0VtbowyuAL26ibcz0ka6xCTH5g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "14.2.18",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.18.tgz",
+ "integrity": "sha512-mliTfa8seVSpTbVEcKEXGjC18+TDII8ykW4a36au97spm9XMPqQTpdGPNBJ9RySSFw9/hLuaCMByluQIAnkzlw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "14.2.18",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.18.tgz",
+ "integrity": "sha512-J5g0UFPbAjKYmqS3Cy7l2fetFmWMY9Oao32eUsBPYohts26BdrMUyfCJnZFQkX9npYaHNDOWqZ6uV9hSDPw9NA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-ia32-msvc": {
+ "version": "14.2.18",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.18.tgz",
+ "integrity": "sha512-Ynxuk4ZgIpdcN7d16ivJdjsDG1+3hTvK24Pp8DiDmIa2+A4CfhJSEHHVndCHok6rnLUzAZD+/UOKESQgTsAZGg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "14.2.18",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.18.tgz",
+ "integrity": "sha512-dtRGMhiU9TN5nyhwzce+7c/4CCeykYS+ipY/4mIrGzJ71+7zNo55ZxCB7cAVuNqdwtYniFNR2c9OFQ6UdFIMcg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@swc/counter": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
+ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.5",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz",
+ "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/counter": "^0.1.3",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "20.16.10",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.10.tgz",
+ "integrity": "sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.19.2"
+ }
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.12",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz",
+ "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*"
+ }
+ },
+ "node_modules/busboy": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+ "dependencies": {
+ "streamsearch": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=10.16.0"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001791",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
+ "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "license": "ISC"
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/next": {
+ "version": "14.2.18",
+ "resolved": "https://registry.npmjs.org/next/-/next-14.2.18.tgz",
+ "integrity": "sha512-H9qbjDuGivUDEnK6wa+p2XKO+iMzgVgyr9Zp/4Iv29lKa+DYaxJGjOeEA+5VOvJh/M7HLiskehInSa0cWxVXUw==",
+ "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "14.2.18",
+ "@swc/helpers": "0.5.5",
+ "busboy": "1.6.0",
+ "caniuse-lite": "^1.0.30001579",
+ "graceful-fs": "^4.2.11",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.1"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": ">=18.17.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "14.2.18",
+ "@next/swc-darwin-x64": "14.2.18",
+ "@next/swc-linux-arm64-gnu": "14.2.18",
+ "@next/swc-linux-arm64-musl": "14.2.18",
+ "@next/swc-linux-x64-gnu": "14.2.18",
+ "@next/swc-linux-x64-musl": "14.2.18",
+ "@next/swc-win32-arm64-msvc": "14.2.18",
+ "@next/swc-win32-ia32-msvc": "14.2.18",
+ "@next/swc-win32-x64-msvc": "14.2.18"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.41.2",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/streamsearch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/styled-jsx": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
+ "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==",
+ "license": "MIT",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.6.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
+ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.19.8",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
+ "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/agents/web-agent/package.json b/agents/web-agent/package.json
new file mode 100644
index 0000000..86f732b
--- /dev/null
+++ b/agents/web-agent/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "pd-web",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Public-facing Next.js 14 site for the doctored.com / drvouch.com healthcare community platform. Backed by pd-api at PD_API_BASE.",
+ "scripts": {
+ "dev": "next dev -p 9876",
+ "build": "next build",
+ "start": "next start -p 9876",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "next": "14.2.18",
+ "react": "18.3.1",
+ "react-dom": "18.3.1"
+ },
+ "devDependencies": {
+ "@types/node": "20.16.10",
+ "@types/react": "18.3.12",
+ "@types/react-dom": "18.3.1",
+ "typescript": "5.6.3"
+ },
+ "engines": { "node": ">=20" }
+}
diff --git a/agents/web-agent/tsconfig.json b/agents/web-agent/tsconfig.json
new file mode 100644
index 0000000..4d25345
--- /dev/null
+++ b/agents/web-agent/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "es2022",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [{ "name": "next" }],
+ "paths": { "@/*": ["./*"] }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/agents/web-agent/tsconfig.tsbuildinfo b/agents/web-agent/tsconfig.tsbuildinfo
new file mode 100644
index 0000000..6b0ff8c
--- /dev/null
+++ b/agents/web-agent/tsconfig.tsbuildinfo
@@ -0,0 +1 @@
+{"fileNames":["./node_modules/typescript/lib/lib.es5.d.ts","./node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/typescript/lib/lib.es2023.d.ts","./node_modules/typescript/lib/lib.esnext.d.ts","./node_modules/typescript/lib/lib.dom.d.ts","./node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/typescript/lib/lib.es2022.object.d.ts","./node_modules/typescript/lib/lib.es2022.sharedmemory.d.ts","./node_modules/typescript/lib/lib.es2022.string.d.ts","./node_modules/typescript/lib/lib.es2022.regexp.d.ts","./node_modules/typescript/lib/lib.es2023.array.d.ts","./node_modules/typescript/lib/lib.es2023.collection.d.ts","./node_modules/typescript/lib/lib.es2023.intl.d.ts","./node_modules/typescript/lib/lib.esnext.array.d.ts","./node_modules/typescript/lib/lib.esnext.collection.d.ts","./node_modules/typescript/lib/lib.esnext.intl.d.ts","./node_modules/typescript/lib/lib.esnext.disposable.d.ts","./node_modules/typescript/lib/lib.esnext.string.d.ts","./node_modules/typescript/lib/lib.esnext.promise.d.ts","./node_modules/typescript/lib/lib.esnext.decorators.d.ts","./node_modules/typescript/lib/lib.esnext.object.d.ts","./node_modules/typescript/lib/lib.esnext.regexp.d.ts","./node_modules/typescript/lib/lib.esnext.iterator.d.ts","./node_modules/typescript/lib/lib.decorators.d.ts","./node_modules/typescript/lib/lib.decorators.legacy.d.ts","./node_modules/next/dist/styled-jsx/types/css.d.ts","./node_modules/@types/react/global.d.ts","./node_modules/csstype/index.d.ts","./node_modules/@types/prop-types/index.d.ts","./node_modules/@types/react/index.d.ts","./node_modules/next/dist/styled-jsx/types/index.d.ts","./node_modules/next/dist/styled-jsx/types/macro.d.ts","./node_modules/next/dist/styled-jsx/types/style.d.ts","./node_modules/next/dist/styled-jsx/types/global.d.ts","./node_modules/next/dist/shared/lib/amp.d.ts","./node_modules/next/amp.d.ts","./node_modules/@types/node/ts5.6/globals.typedarray.d.ts","./node_modules/@types/node/ts5.6/buffer.buffer.d.ts","./node_modules/@types/node/assert.d.ts","./node_modules/@types/node/assert/strict.d.ts","./node_modules/undici-types/header.d.ts","./node_modules/undici-types/readable.d.ts","./node_modules/undici-types/file.d.ts","./node_modules/undici-types/fetch.d.ts","./node_modules/undici-types/formdata.d.ts","./node_modules/undici-types/connector.d.ts","./node_modules/undici-types/client.d.ts","./node_modules/undici-types/errors.d.ts","./node_modules/undici-types/dispatcher.d.ts","./node_modules/undici-types/global-dispatcher.d.ts","./node_modules/undici-types/global-origin.d.ts","./node_modules/undici-types/pool-stats.d.ts","./node_modules/undici-types/pool.d.ts","./node_modules/undici-types/handlers.d.ts","./node_modules/undici-types/balanced-pool.d.ts","./node_modules/undici-types/agent.d.ts","./node_modules/undici-types/mock-interceptor.d.ts","./node_modules/undici-types/mock-agent.d.ts","./node_modules/undici-types/mock-client.d.ts","./node_modules/undici-types/mock-pool.d.ts","./node_modules/undici-types/mock-errors.d.ts","./node_modules/undici-types/proxy-agent.d.ts","./node_modules/undici-types/env-http-proxy-agent.d.ts","./node_modules/undici-types/retry-handler.d.ts","./node_modules/undici-types/retry-agent.d.ts","./node_modules/undici-types/api.d.ts","./node_modules/undici-types/interceptors.d.ts","./node_modules/undici-types/util.d.ts","./node_modules/undici-types/cookies.d.ts","./node_modules/undici-types/patch.d.ts","./node_modules/undici-types/websocket.d.ts","./node_modules/undici-types/eventsource.d.ts","./node_modules/undici-types/filereader.d.ts","./node_modules/undici-types/diagnostics-channel.d.ts","./node_modules/undici-types/content-type.d.ts","./node_modules/undici-types/cache.d.ts","./node_modules/undici-types/index.d.ts","./node_modules/@types/node/globals.d.ts","./node_modules/@types/node/async_hooks.d.ts","./node_modules/@types/node/buffer.d.ts","./node_modules/@types/node/child_process.d.ts","./node_modules/@types/node/cluster.d.ts","./node_modules/@types/node/console.d.ts","./node_modules/@types/node/constants.d.ts","./node_modules/@types/node/crypto.d.ts","./node_modules/@types/node/dgram.d.ts","./node_modules/@types/node/diagnostics_channel.d.ts","./node_modules/@types/node/dns.d.ts","./node_modules/@types/node/dns/promises.d.ts","./node_modules/@types/node/domain.d.ts","./node_modules/@types/node/dom-events.d.ts","./node_modules/@types/node/events.d.ts","./node_modules/@types/node/fs.d.ts","./node_modules/@types/node/fs/promises.d.ts","./node_modules/@types/node/http.d.ts","./node_modules/@types/node/http2.d.ts","./node_modules/@types/node/https.d.ts","./node_modules/@types/node/inspector.d.ts","./node_modules/@types/node/module.d.ts","./node_modules/@types/node/net.d.ts","./node_modules/@types/node/os.d.ts","./node_modules/@types/node/path.d.ts","./node_modules/@types/node/perf_hooks.d.ts","./node_modules/@types/node/process.d.ts","./node_modules/@types/node/punycode.d.ts","./node_modules/@types/node/querystring.d.ts","./node_modules/@types/node/readline.d.ts","./node_modules/@types/node/readline/promises.d.ts","./node_modules/@types/node/repl.d.ts","./node_modules/@types/node/sea.d.ts","./node_modules/@types/node/stream.d.ts","./node_modules/@types/node/stream/promises.d.ts","./node_modules/@types/node/stream/consumers.d.ts","./node_modules/@types/node/stream/web.d.ts","./node_modules/@types/node/string_decoder.d.ts","./node_modules/@types/node/test.d.ts","./node_modules/@types/node/timers.d.ts","./node_modules/@types/node/timers/promises.d.ts","./node_modules/@types/node/tls.d.ts","./node_modules/@types/node/trace_events.d.ts","./node_modules/@types/node/tty.d.ts","./node_modules/@types/node/url.d.ts","./node_modules/@types/node/util.d.ts","./node_modules/@types/node/v8.d.ts","./node_modules/@types/node/vm.d.ts","./node_modules/@types/node/wasi.d.ts","./node_modules/@types/node/worker_threads.d.ts","./node_modules/@types/node/zlib.d.ts","./node_modules/@types/node/globals.global.d.ts","./node_modules/@types/node/ts5.6/index.d.ts","./node_modules/next/dist/server/get-page-files.d.ts","./node_modules/@types/react/canary.d.ts","./node_modules/@types/react/experimental.d.ts","./node_modules/@types/react-dom/index.d.ts","./node_modules/@types/react-dom/canary.d.ts","./node_modules/@types/react-dom/experimental.d.ts","./node_modules/next/dist/compiled/webpack/webpack.d.ts","./node_modules/next/dist/server/config.d.ts","./node_modules/next/dist/lib/load-custom-routes.d.ts","./node_modules/next/dist/shared/lib/image-config.d.ts","./node_modules/next/dist/build/webpack/plugins/subresource-integrity-plugin.d.ts","./node_modules/next/dist/server/body-streams.d.ts","./node_modules/next/dist/server/future/route-kind.d.ts","./node_modules/next/dist/server/future/route-definitions/route-definition.d.ts","./node_modules/next/dist/server/future/route-matches/route-match.d.ts","./node_modules/next/dist/client/components/app-router-headers.d.ts","./node_modules/next/dist/server/request-meta.d.ts","./node_modules/next/dist/server/lib/revalidate.d.ts","./node_modules/next/dist/server/config-shared.d.ts","./node_modules/next/dist/server/base-http/index.d.ts","./node_modules/next/dist/server/api-utils/index.d.ts","./node_modules/next/dist/server/node-environment.d.ts","./node_modules/next/dist/server/require-hook.d.ts","./node_modules/next/dist/server/node-polyfill-crypto.d.ts","./node_modules/next/dist/lib/page-types.d.ts","./node_modules/next/dist/build/analysis/get-page-static-info.d.ts","./node_modules/next/dist/build/webpack/loaders/get-module-build-info.d.ts","./node_modules/next/dist/build/webpack/plugins/middleware-plugin.d.ts","./node_modules/next/dist/server/render-result.d.ts","./node_modules/next/dist/server/future/helpers/i18n-provider.d.ts","./node_modules/next/dist/server/web/next-url.d.ts","./node_modules/next/dist/compiled/@edge-runtime/cookies/index.d.ts","./node_modules/next/dist/server/web/spec-extension/cookies.d.ts","./node_modules/next/dist/server/web/spec-extension/request.d.ts","./node_modules/next/dist/server/web/spec-extension/fetch-event.d.ts","./node_modules/next/dist/server/web/spec-extension/response.d.ts","./node_modules/next/dist/server/web/types.d.ts","./node_modules/next/dist/lib/setup-exception-listeners.d.ts","./node_modules/next/dist/lib/constants.d.ts","./node_modules/next/dist/build/index.d.ts","./node_modules/next/dist/build/webpack/plugins/pages-manifest-plugin.d.ts","./node_modules/next/dist/shared/lib/router/utils/route-regex.d.ts","./node_modules/next/dist/shared/lib/router/utils/route-matcher.d.ts","./node_modules/next/dist/shared/lib/router/utils/parse-url.d.ts","./node_modules/next/dist/server/base-http/node.d.ts","./node_modules/next/dist/server/font-utils.d.ts","./node_modules/next/dist/build/webpack/plugins/flight-manifest-plugin.d.ts","./node_modules/next/dist/server/future/route-modules/route-module.d.ts","./node_modules/next/dist/shared/lib/deep-readonly.d.ts","./node_modules/next/dist/server/load-components.d.ts","./node_modules/next/dist/shared/lib/router/utils/middleware-route-matcher.d.ts","./node_modules/next/dist/build/webpack/plugins/next-font-manifest-plugin.d.ts","./node_modules/next/dist/server/future/route-definitions/locale-route-definition.d.ts","./node_modules/next/dist/server/future/route-definitions/pages-route-definition.d.ts","./node_modules/next/dist/shared/lib/mitt.d.ts","./node_modules/next/dist/client/with-router.d.ts","./node_modules/next/dist/client/router.d.ts","./node_modules/next/dist/client/route-loader.d.ts","./node_modules/next/dist/client/page-loader.d.ts","./node_modules/next/dist/shared/lib/bloom-filter.d.ts","./node_modules/next/dist/shared/lib/router/router.d.ts","./node_modules/next/dist/shared/lib/router-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/loadable-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/loadable.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/image-config-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/hooks-client-context.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/head-manager-context.shared-runtime.d.ts","./node_modules/next/dist/server/future/route-definitions/app-page-route-definition.d.ts","./node_modules/next/dist/shared/lib/modern-browserslist-target.d.ts","./node_modules/next/dist/shared/lib/constants.d.ts","./node_modules/next/dist/build/webpack/loaders/metadata/types.d.ts","./node_modules/next/dist/build/page-extensions-type.d.ts","./node_modules/next/dist/build/webpack/loaders/next-app-loader.d.ts","./node_modules/next/dist/server/lib/app-dir-module.d.ts","./node_modules/next/dist/server/response-cache/types.d.ts","./node_modules/next/dist/server/response-cache/index.d.ts","./node_modules/next/dist/server/lib/incremental-cache/index.d.ts","./node_modules/next/dist/client/components/hooks-server-context.d.ts","./node_modules/next/dist/server/app-render/dynamic-rendering.d.ts","./node_modules/next/dist/client/components/static-generation-async-storage-instance.d.ts","./node_modules/next/dist/client/components/static-generation-async-storage.external.d.ts","./node_modules/next/dist/server/web/spec-extension/adapters/request-cookies.d.ts","./node_modules/next/dist/server/async-storage/draft-mode-provider.d.ts","./node_modules/next/dist/server/web/spec-extension/adapters/headers.d.ts","./node_modules/next/dist/client/components/request-async-storage-instance.d.ts","./node_modules/next/dist/client/components/request-async-storage.external.d.ts","./node_modules/next/dist/server/app-render/create-error-handler.d.ts","./node_modules/next/dist/server/app-render/app-render.d.ts","./node_modules/next/dist/shared/lib/server-inserted-html.shared-runtime.d.ts","./node_modules/next/dist/shared/lib/amp-context.shared-runtime.d.ts","./node_modules/next/dist/server/future/route-modules/app-page/vendored/contexts/entrypoints.d.ts","./node_modules/next/dist/server/future/route-modules/app-page/module.compiled.d.ts","./node_modules/@types/react/jsx-runtime.d.ts","./node_modules/next/dist/client/components/error-boundary.d.ts","./node_modules/next/dist/client/components/router-reducer/create-initial-router-state.d.ts","./node_modules/next/dist/client/components/app-router.d.ts","./node_modules/next/dist/client/components/layout-router.d.ts","./node_modules/next/dist/client/components/render-from-template-context.d.ts","./node_modules/next/dist/client/components/action-async-storage-instance.d.ts","./node_modules/next/dist/client/components/action-async-storage.external.d.ts","./node_modules/next/dist/client/components/client-page.d.ts","./node_modules/next/dist/client/components/search-params.d.ts","./node_modules/next/dist/client/components/not-found-boundary.d.ts","./node_modules/next/dist/server/app-render/rsc/preloads.d.ts","./node_modules/next/dist/server/app-render/rsc/postpone.d.ts","./node_modules/next/dist/server/app-render/rsc/taint.d.ts","./node_modules/next/dist/server/app-render/entry-base.d.ts","./node_modules/next/dist/build/templates/app-page.d.ts","./node_modules/next/dist/server/future/route-modules/app-page/module.d.ts","./node_modules/next/dist/server/app-render/types.d.ts","./node_modules/next/dist/client/components/router-reducer/fetch-server-response.d.ts","./node_modules/next/dist/client/components/router-reducer/router-reducer-types.d.ts","./node_modules/next/dist/shared/lib/app-router-context.shared-runtime.d.ts","./node_modules/next/dist/server/future/route-modules/pages/vendored/contexts/entrypoints.d.ts","./node_modules/next/dist/server/future/route-modules/pages/module.compiled.d.ts","./node_modules/next/dist/build/templates/pages.d.ts","./node_modules/next/dist/server/future/route-modules/pages/module.d.ts","./node_modules/next/dist/server/render.d.ts","./node_modules/next/dist/server/future/route-definitions/pages-api-route-definition.d.ts","./node_modules/next/dist/server/future/route-matches/pages-api-route-match.d.ts","./node_modules/next/dist/server/future/route-matchers/route-matcher.d.ts","./node_modules/next/dist/server/future/route-matcher-providers/route-matcher-provider.d.ts","./node_modules/next/dist/server/future/route-matcher-managers/route-matcher-manager.d.ts","./node_modules/next/dist/server/future/normalizers/normalizer.d.ts","./node_modules/next/dist/server/future/normalizers/locale-route-normalizer.d.ts","./node_modules/next/dist/server/future/normalizers/request/pathname-normalizer.d.ts","./node_modules/next/dist/server/future/normalizers/request/suffix.d.ts","./node_modules/next/dist/server/future/normalizers/request/rsc.d.ts","./node_modules/next/dist/server/future/normalizers/request/prefix.d.ts","./node_modules/next/dist/server/future/normalizers/request/postponed.d.ts","./node_modules/next/dist/server/future/normalizers/request/action.d.ts","./node_modules/next/dist/server/future/normalizers/request/prefetch-rsc.d.ts","./node_modules/next/dist/server/future/normalizers/request/next-data.d.ts","./node_modules/next/dist/server/base-server.d.ts","./node_modules/next/dist/server/image-optimizer.d.ts","./node_modules/next/dist/server/next-server.d.ts","./node_modules/next/dist/lib/coalesced-function.d.ts","./node_modules/next/dist/server/lib/router-utils/types.d.ts","./node_modules/next/dist/trace/types.d.ts","./node_modules/next/dist/trace/trace.d.ts","./node_modules/next/dist/trace/shared.d.ts","./node_modules/next/dist/trace/index.d.ts","./node_modules/next/dist/build/load-jsconfig.d.ts","./node_modules/next/dist/build/webpack-config.d.ts","./node_modules/next/dist/build/webpack/plugins/define-env-plugin.d.ts","./node_modules/next/dist/build/swc/index.d.ts","./node_modules/next/dist/server/dev/parse-version-info.d.ts","./node_modules/next/dist/server/dev/hot-reloader-types.d.ts","./node_modules/next/dist/telemetry/storage.d.ts","./node_modules/next/dist/server/lib/types.d.ts","./node_modules/next/dist/server/lib/render-server.d.ts","./node_modules/next/dist/server/lib/router-server.d.ts","./node_modules/next/dist/shared/lib/router/utils/path-match.d.ts","./node_modules/next/dist/server/lib/router-utils/filesystem.d.ts","./node_modules/next/dist/server/lib/router-utils/setup-dev-bundler.d.ts","./node_modules/next/dist/server/lib/dev-bundler-service.d.ts","./node_modules/next/dist/server/dev/static-paths-worker.d.ts","./node_modules/next/dist/server/dev/next-dev-server.d.ts","./node_modules/next/dist/server/next.d.ts","./node_modules/next/dist/lib/metadata/types/alternative-urls-types.d.ts","./node_modules/next/dist/lib/metadata/types/extra-types.d.ts","./node_modules/next/dist/lib/metadata/types/metadata-types.d.ts","./node_modules/next/dist/lib/metadata/types/manifest-types.d.ts","./node_modules/next/dist/lib/metadata/types/opengraph-types.d.ts","./node_modules/next/dist/lib/metadata/types/twitter-types.d.ts","./node_modules/next/dist/lib/metadata/types/metadata-interface.d.ts","./node_modules/next/types/index.d.ts","./node_modules/next/dist/shared/lib/html-context.shared-runtime.d.ts","./node_modules/@next/env/dist/index.d.ts","./node_modules/next/dist/shared/lib/utils.d.ts","./node_modules/next/dist/pages/_app.d.ts","./node_modules/next/app.d.ts","./node_modules/next/dist/server/web/spec-extension/unstable-cache.d.ts","./node_modules/next/dist/server/web/spec-extension/revalidate.d.ts","./node_modules/next/dist/server/web/spec-extension/unstable-no-store.d.ts","./node_modules/next/cache.d.ts","./node_modules/next/dist/shared/lib/runtime-config.external.d.ts","./node_modules/next/config.d.ts","./node_modules/next/dist/pages/_document.d.ts","./node_modules/next/document.d.ts","./node_modules/next/dist/shared/lib/dynamic.d.ts","./node_modules/next/dynamic.d.ts","./node_modules/next/dist/pages/_error.d.ts","./node_modules/next/error.d.ts","./node_modules/next/dist/shared/lib/head.d.ts","./node_modules/next/head.d.ts","./node_modules/next/dist/client/components/draft-mode.d.ts","./node_modules/next/dist/client/components/headers.d.ts","./node_modules/next/headers.d.ts","./node_modules/next/dist/shared/lib/get-img-props.d.ts","./node_modules/next/dist/client/image-component.d.ts","./node_modules/next/dist/shared/lib/image-external.d.ts","./node_modules/next/image.d.ts","./node_modules/next/dist/client/link.d.ts","./node_modules/next/link.d.ts","./node_modules/next/dist/client/components/redirect-status-code.d.ts","./node_modules/next/dist/client/components/redirect.d.ts","./node_modules/next/dist/client/components/not-found.d.ts","./node_modules/next/dist/client/components/navigation.react-server.d.ts","./node_modules/next/dist/client/components/navigation.d.ts","./node_modules/next/navigation.d.ts","./node_modules/next/router.d.ts","./node_modules/next/dist/client/script.d.ts","./node_modules/next/script.d.ts","./node_modules/next/dist/server/web/spec-extension/user-agent.d.ts","./node_modules/next/dist/compiled/@edge-runtime/primitives/url.d.ts","./node_modules/next/dist/server/web/spec-extension/image-response.d.ts","./node_modules/next/dist/compiled/@vercel/og/satori/index.d.ts","./node_modules/next/dist/compiled/@vercel/og/emoji/index.d.ts","./node_modules/next/dist/compiled/@vercel/og/types.d.ts","./node_modules/next/server.d.ts","./node_modules/next/types/global.d.ts","./node_modules/next/types/compiled.d.ts","./node_modules/next/index.d.ts","./node_modules/next/image-types/global.d.ts","./next-env.d.ts","./app/layout.tsx","./app/page.tsx","./app/account/page.tsx","./app/account/claim/page.tsx","./app/community/page.tsx","./app/doctors/[slug]/page.tsx","./app/mockups/page.tsx","./app/orgs/[slug]/page.tsx","./app/search/page.tsx","./.next/types/app/layout.ts","./.next/types/app/page.ts","./.next/types/app/account/page.ts","./.next/types/app/community/page.ts","./.next/types/app/doctors/[slug]/page.ts","./.next/types/app/mockups/page.ts","./.next/types/app/orgs/[slug]/page.ts","./.next/types/app/search/page.ts"],"fileIdsList":[[87,129,345,398],[87,129,345,400],[87,129,345,401],[87,129,345,396],[87,129,345,402],[87,129,345,403],[87,129,345,397],[87,129,345,404],[79,87,129],[87,129],[87,129,393],[87,129,142,151],[87,129,393,394],[87,88,129],[87,128,129],[87,129,134,163],[87,129,130,135,141,142,149,160,171],[87,129,130,131,141,149],[87,129,132,172],[87,129,133,134,142,150],[87,129,134,160,168],[87,129,135,137,141,149],[87,128,129,136],[87,129,137,138],[87,129,141],[87,129,139,141],[87,128,129,141],[87,129,141,142,143,160,171],[87,129,141,142,143,156,160,163],[87,126,129,176],[87,129,137,141,144,149,160,171],[87,129,141,142,144,145,149,160,168,171],[87,129,144,146,160,168,171],[87,129,141,147],[87,129,148,171,176],[87,129,137,141,149,160],[87,129,150],[87,129,151],[87,128,129,152],[87,88,89,128,129,130,131,132,133,134,135,136,137,138,139,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177],[87,129,154],[87,129,155],[87,129,141,156,157],[87,129,156,158,172,174],[87,129,141,160,161,162,163],[87,129,160,162],[87,129,160,161],[87,129,163],[87,129,164],[87,88,129,160],[87,129,141,166,167],[87,129,166,167],[87,129,134,149,160,168],[87,129,169],[129],[86,87,88,89,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178],[87,129,149,170],[87,129,144,155,171],[87,129,134,172],[87,129,160,173],[87,129,148,174],[87,129,175],[87,129,134,141,143,152,160,171,174,176],[87,129,160,177],[79,87,129,183,184,185],[79,87,129,183,184],[79,83,87,129,182,346,389],[79,83,87,129,181,346,389],[76,77,78,87,129],[84,87,129],[87,129,350],[87,129,352,353,354],[87,129,356],[87,129,188,198,204,206,346],[87,129,188,195,197,200,218],[87,129,198],[87,129,198,200,324],[87,129,253,271,286,392],[87,129,294],[87,129,188,198,205,239,249,321,322,392],[87,129,205,392],[87,129,198,249,250,251,392],[87,129,198,205,239,392],[87,129,392],[87,129,188,205,206,392],[87,129,279],[87,128,129,179,278],[79,87,129,272,273,274,291,292],[79,87,129,272],[87,129,262],[87,129,261,263,366],[79,87,129,272,273,289],[87,129,268,292,378],[87,129,376,377],[87,129,212,375],[87,129,265],[87,128,129,179,212,228,261,262,263,264],[79,87,129,289,291,292],[87,129,289,291],[87,129,289,290,292],[87,129,155,179],[87,129,260],[87,128,129,179,197,199,256,257,258,259],[79,87,129,189,369],[79,87,129,171,179],[79,87,129,205,237],[79,87,129,205],[87,129,235,240],[79,87,129,236,349],[79,83,87,129,144,179,181,182,346,387,388],[87,129,346],[87,129,187],[87,129,339,340,341,342,343,344],[87,129,341],[79,87,129,236,272,349],[79,87,129,272,347,349],[79,87,129,272,349],[87,129,144,179,199,349],[87,129,144,179,196,197,208,226,228,260,265,266,288,289],[87,129,257,260,265,273,275,276,277,279,280,281,282,283,284,285,392],[87,129,258],[79,87,129,155,179,197,198,226,228,229,231,256,288,292,346,392],[87,129,144,179,199,200,212,213,261],[87,129,144,179,198,200],[87,129,144,160,179,196,199,200],[87,129,144,155,171,179,196,197,198,199,200,205,208,209,219,220,222,225,226,228,229,230,231,255,256,289,297,299,302,304,307,309,310,311,312],[87,129,144,160,179],[87,129,188,189,190,196,197,346,349,392],[87,129,144,160,171,179,193,323,325,326,392],[87,129,155,171,179,193,196,199,216,220,222,223,224,229,256,302,313,315,321,335,336],[87,129,198,202,256],[87,129,196,198],[87,129,209,303],[87,129,305,306],[87,129,305],[87,129,303],[87,129,305,308],[87,129,192,193],[87,129,192,232],[87,129,192],[87,129,194,209,301],[87,129,300],[87,129,193,194],[87,129,194,298],[87,129,193],[87,129,288],[87,129,144,179,196,208,227,247,253,267,270,287,289],[87,129,241,242,243,244,245,246,268,269,292,347],[87,129,296],[87,129,144,179,196,208,227,233,293,295,297,346,349],[87,129,144,171,179,189,196,198,255],[87,129,252],[87,129,144,179,329,334],[87,129,219,228,255,349],[87,129,317,321,335,338],[87,129,144,202,321,329,330,338],[87,129,188,198,219,230,332],[87,129,144,179,198,205,230,316,317,327,328,331,333],[87,129,180,226,227,228,346,349],[87,129,144,155,171,179,194,196,197,199,202,207,208,216,219,220,222,223,224,225,229,231,255,256,299,313,314,349],[87,129,144,179,196,198,202,315,337],[87,129,144,179,197,199],[79,87,129,144,155,179,187,189,196,197,200,208,225,226,228,229,231,296,346,349],[87,129,144,155,171,179,191,194,195,199],[87,129,192,254],[87,129,144,179,192,197,208],[87,129,144,179,198,209],[87,129,144,179],[87,129,212],[87,129,211],[87,129,213],[87,129,198,210,212,216],[87,129,198,210,212],[87,129,144,179,191,198,199,205,213,214,215],[79,87,129,289,290,291],[87,129,248],[79,87,129,189],[79,87,129,222],[79,87,129,180,225,228,231,346,349],[87,129,189,369,370],[79,87,129,240],[79,87,129,155,171,179,187,234,236,238,239,349],[87,129,199,205,222],[87,129,221],[79,87,129,142,144,155,179,187,240,249,346,347,348],[75,79,80,81,82,87,129,181,182,346,389],[87,129,134],[87,129,318,319,320],[87,129,318],[87,129,358],[87,129,360],[87,129,362],[87,129,364],[87,129,367],[87,129,371],[83,85,87,129,346,351,355,357,359,361,363,365,368,372,374,380,381,383,390,391,392],[87,129,373],[87,129,379],[87,129,236],[87,129,382],[87,128,129,213,214,215,216,384,385,386,389],[87,129,179],[79,83,87,129,144,146,155,179,181,182,183,185,187,200,338,345,349,389],[87,98,102,129,171],[87,98,129,160,171],[87,93,129],[87,95,98,129,168,171],[87,129,149,168],[87,93,129,179],[87,95,98,129,149,171],[87,90,91,94,97,129,141,160,171],[87,98,105,129],[87,90,96,129],[87,98,119,120,129],[87,94,98,129,163,171,179],[87,119,129,179],[87,92,93,129,179],[87,98,129],[87,92,93,94,95,96,97,98,99,100,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,120,121,122,123,124,125,129],[87,98,113,129],[87,98,105,106,129],[87,96,98,106,107,129],[87,97,129],[87,90,93,98,129],[87,98,102,106,107,129],[87,102,129],[87,96,98,101,129,171],[87,90,95,98,105,129],[87,129,160],[87,93,98,119,129,176,179]],"fileInfos":[{"version":"44e584d4f6444f58791784f1d530875970993129442a847597db702a073ca68c","affectsGlobalScope":true,"impliedFormat":1},{"version":"45b7ab580deca34ae9729e97c13cfd999df04416a79116c3bfb483804f85ded4","impliedFormat":1},{"version":"3facaf05f0c5fc569c5649dd359892c98a85557e3e0c847964caeb67076f4d75","impliedFormat":1},{"version":"9a68c0c07ae2fa71b44384a839b7b8d81662a236d4b9ac30916718f7510b1b2d","impliedFormat":1},{"version":"5e1c4c362065a6b95ff952c0eab010f04dcd2c3494e813b493ecfd4fcb9fc0d8","impliedFormat":1},{"version":"68d73b4a11549f9c0b7d352d10e91e5dca8faa3322bfb77b661839c42b1ddec7","impliedFormat":1},{"version":"5efce4fc3c29ea84e8928f97adec086e3dc876365e0982cc8479a07954a3efd4","impliedFormat":1},{"version":"feecb1be483ed332fad555aff858affd90a48ab19ba7272ee084704eb7167569","impliedFormat":1},{"version":"5514e54f17d6d74ecefedc73c504eadffdeda79c7ea205cf9febead32d45c4bc","impliedFormat":1},{"version":"27bdc30a0e32783366a5abeda841bc22757c1797de8681bbe81fbc735eeb1c10","impliedFormat":1},{"version":"abee51ebffafd50c07d76be5848a34abfe4d791b5745ef1e5648718722fab924","impliedFormat":1},{"version":"9e8ca8ed051c2697578c023d9c29d6df689a083561feba5c14aedee895853999","affectsGlobalScope":true,"impliedFormat":1},{"version":"69e65d976bf166ce4a9e6f6c18f94d2424bf116e90837ace179610dbccad9b42","affectsGlobalScope":true,"impliedFormat":1},{"version":"6920e1448680767498a0b77c6a00a8e77d14d62c3da8967b171f1ddffa3c18e4","affectsGlobalScope":true,"impliedFormat":1},{"version":"dc2df20b1bcdc8c2d34af4926e2c3ab15ffe1160a63e58b7e09833f616efff44","affectsGlobalScope":true,"impliedFormat":1},{"version":"515d0b7b9bea2e31ea4ec968e9edd2c39d3eebf4a2d5cbd04e88639819ae3b71","affectsGlobalScope":true,"impliedFormat":1},{"version":"45d8ccb3dfd57355eb29749919142d4321a0aa4df6acdfc54e30433d7176600a","affectsGlobalScope":true,"impliedFormat":1},{"version":"0dc1e7ceda9b8b9b455c3a2d67b0412feab00bd2f66656cd8850e8831b08b537","affectsGlobalScope":true,"impliedFormat":1},{"version":"ce691fb9e5c64efb9547083e4a34091bcbe5bdb41027e310ebba8f7d96a98671","affectsGlobalScope":true,"impliedFormat":1},{"version":"8d697a2a929a5fcb38b7a65594020fcef05ec1630804a33748829c5ff53640d0","affectsGlobalScope":true,"impliedFormat":1},{"version":"4ff2a353abf8a80ee399af572debb8faab2d33ad38c4b4474cff7f26e7653b8d","affectsGlobalScope":true,"impliedFormat":1},{"version":"93495ff27b8746f55d19fcbcdbaccc99fd95f19d057aed1bd2c0cafe1335fbf0","affectsGlobalScope":true,"impliedFormat":1},{"version":"6fc23bb8c3965964be8c597310a2878b53a0306edb71d4b5a4dfe760186bcc01","affectsGlobalScope":true,"impliedFormat":1},{"version":"ea011c76963fb15ef1cdd7ce6a6808b46322c527de2077b6cfdf23ae6f5f9ec7","affectsGlobalScope":true,"impliedFormat":1},{"version":"38f0219c9e23c915ef9790ab1d680440d95419ad264816fa15009a8851e79119","affectsGlobalScope":true,"impliedFormat":1},{"version":"69ab18c3b76cd9b1be3d188eaf8bba06112ebbe2f47f6c322b5105a6fbc45a2e","affectsGlobalScope":true,"impliedFormat":1},{"version":"4738f2420687fd85629c9efb470793bb753709c2379e5f85bc1815d875ceadcd","affectsGlobalScope":true,"impliedFormat":1},{"version":"2f11ff796926e0832f9ae148008138ad583bd181899ab7dd768a2666700b1893","affectsGlobalScope":true,"impliedFormat":1},{"version":"4de680d5bb41c17f7f68e0419412ca23c98d5749dcaaea1896172f06435891fc","affectsGlobalScope":true,"impliedFormat":1},{"version":"9fc46429fbe091ac5ad2608c657201eb68b6f1b8341bd6d670047d32ed0a88fa","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac9538681b19688c8eae65811b329d3744af679e0bdfa5d842d0e32524c73e1c","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a969edff4bd52585473d24995c5ef223f6652d6ef46193309b3921d65dd4376","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e9fbd7030c440b33d021da145d3232984c8bb7916f277e8ffd3dc2e3eae2bdb","affectsGlobalScope":true,"impliedFormat":1},{"version":"811ec78f7fefcabbda4bfa93b3eb67d9ae166ef95f9bff989d964061cbf81a0c","affectsGlobalScope":true,"impliedFormat":1},{"version":"717937616a17072082152a2ef351cb51f98802fb4b2fdabd32399843875974ca","affectsGlobalScope":true,"impliedFormat":1},{"version":"d7e7d9b7b50e5f22c915b525acc5a49a7a6584cf8f62d0569e557c5cfc4b2ac2","affectsGlobalScope":true,"impliedFormat":1},{"version":"71c37f4c9543f31dfced6c7840e068c5a5aacb7b89111a4364b1d5276b852557","affectsGlobalScope":true,"impliedFormat":1},{"version":"576711e016cf4f1804676043e6a0a5414252560eb57de9faceee34d79798c850","affectsGlobalScope":true,"impliedFormat":1},{"version":"89c1b1281ba7b8a96efc676b11b264de7a8374c5ea1e6617f11880a13fc56dc6","affectsGlobalScope":true,"impliedFormat":1},{"version":"74f7fa2d027d5b33eb0471c8e82a6c87216223181ec31247c357a3e8e2fddc5b","affectsGlobalScope":true,"impliedFormat":1},{"version":"1a94697425a99354df73d9c8291e2ecd4dddd370aed4023c2d6dee6cccb32666","affectsGlobalScope":true,"impliedFormat":1},{"version":"063600664504610fe3e99b717a1223f8b1900087fab0b4cad1496a114744f8df","affectsGlobalScope":true,"impliedFormat":1},{"version":"934019d7e3c81950f9a8426d093458b65d5aff2c7c1511233c0fd5b941e608ab","affectsGlobalScope":true,"impliedFormat":1},{"version":"bf14a426dbbf1022d11bd08d6b8e709a2e9d246f0c6c1032f3b2edb9a902adbe","affectsGlobalScope":true,"impliedFormat":1},{"version":"e3f9fc0ec0b96a9e642f11eda09c0be83a61c7b336977f8b9fdb1e9788e925fe","affectsGlobalScope":true,"impliedFormat":1},{"version":"59fb2c069260b4ba00b5643b907ef5d5341b167e7d1dbf58dfd895658bda2867","affectsGlobalScope":true,"impliedFormat":1},{"version":"479553e3779be7d4f68e9f40cdb82d038e5ef7592010100410723ceced22a0f7","affectsGlobalScope":true,"impliedFormat":1},{"version":"368af93f74c9c932edd84c58883e736c9e3d53cec1fe24c0b0ff451f529ceab1","affectsGlobalScope":true,"impliedFormat":1},{"version":"af3dd424cf267428f30ccfc376f47a2c0114546b55c44d8c0f1d57d841e28d74","affectsGlobalScope":true,"impliedFormat":1},{"version":"995c005ab91a498455ea8dfb63aa9f83fa2ea793c3d8aa344be4a1678d06d399","affectsGlobalScope":true,"impliedFormat":1},{"version":"d3d7b04b45033f57351c8434f60b6be1ea71a2dfec2d0a0c3c83badbb0e3e693","affectsGlobalScope":true,"impliedFormat":1},{"version":"956d27abdea9652e8368ce029bb1e0b9174e9678a273529f426df4b3d90abd60","affectsGlobalScope":true,"impliedFormat":1},{"version":"4fa6ed14e98aa80b91f61b9805c653ee82af3502dc21c9da5268d3857772ca05","affectsGlobalScope":true,"impliedFormat":1},{"version":"e6633e05da3ff36e6da2ec170d0d03ccf33de50ca4dc6f5aeecb572cedd162fb","affectsGlobalScope":true,"impliedFormat":1},{"version":"15c1c3d7b2e46e0025417ed6d5f03f419e57e6751f87925ca19dc88297053fe6","affectsGlobalScope":true,"impliedFormat":1},{"version":"8444af78980e3b20b49324f4a16ba35024fef3ee069a0eb67616ea6ca821c47a","affectsGlobalScope":true,"impliedFormat":1},{"version":"caccc56c72713969e1cfe5c3d44e5bab151544d9d2b373d7dbe5a1e4166652be","affectsGlobalScope":true,"impliedFormat":1},{"version":"3287d9d085fbd618c3971944b65b4be57859f5415f495b33a6adc994edd2f004","affectsGlobalScope":true,"impliedFormat":1},{"version":"b4b67b1a91182421f5df999988c690f14d813b9850b40acd06ed44691f6727ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"9d540251809289a05349b70ab5f4b7b99f922af66ab3c39ba56a475dcf95d5ff","affectsGlobalScope":true,"impliedFormat":1},{"version":"436aaf437562f276ec2ddbee2f2cdedac7664c1e4c1d2c36839ddd582eeb3d0a","affectsGlobalScope":true,"impliedFormat":1},{"version":"8e3c06ea092138bf9fa5e874a1fdbc9d54805d074bee1de31b99a11e2fec239d","affectsGlobalScope":true,"impliedFormat":1},{"version":"0b11f3ca66aa33124202c80b70cd203219c3d4460cfc165e0707aa9ec710fc53","affectsGlobalScope":true,"impliedFormat":1},{"version":"6a3f5a0129cc80cf439ab71164334d649b47059a4f5afca90282362407d0c87f","affectsGlobalScope":true,"impliedFormat":1},{"version":"811c71eee4aa0ac5f7adf713323a5c41b0cf6c4e17367a34fbce379e12bbf0a4","affectsGlobalScope":true,"impliedFormat":1},{"version":"51ad4c928303041605b4d7ae32e0c1ee387d43a24cd6f1ebf4a2699e1076d4fa","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a6282c8827e4b9a95f4bf4f5c205673ada31b982f50572d27103df8ceb8013c","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac77cb3e8c6d3565793eb90a8373ee8033146315a3dbead3bde8db5eaf5e5ec6","affectsGlobalScope":true,"impliedFormat":1},{"version":"d4b1d2c51d058fc21ec2629fff7a76249dec2e36e12960ea056e3ef89174080f","affectsGlobalScope":true,"impliedFormat":1},{"version":"2fef54945a13095fdb9b84f705f2b5994597640c46afeb2ce78352fab4cb3279","affectsGlobalScope":true,"impliedFormat":1},{"version":"56e4ed5aab5f5920980066a9409bfaf53e6d21d3f8d020c17e4de584d29600ad","affectsGlobalScope":true,"impliedFormat":1},{"version":"61d6a2092f48af66dbfb220e31eea8b10bc02b6932d6e529005fd2d7b3281290","affectsGlobalScope":true,"impliedFormat":1},{"version":"33358442698bb565130f52ba79bfd3d4d484ac85fe33f3cb1759c54d18201393","affectsGlobalScope":true,"impliedFormat":1},{"version":"782dec38049b92d4e85c1585fbea5474a219c6984a35b004963b00beb1aab538","affectsGlobalScope":true,"impliedFormat":1},{"version":"0990a7576222f248f0a3b888adcb7389f957928ce2afb1cd5128169086ff4d29","impliedFormat":1},{"version":"36a2e4c9a67439aca5f91bb304611d5ae6e20d420503e96c230cf8fcdc948d94","affectsGlobalScope":true,"impliedFormat":1},{"version":"ac51dd7d31333793807a6abaa5ae168512b6131bd41d9c5b98477fc3b7800f9f","impliedFormat":1},{"version":"87d9d29dbc745f182683f63187bf3d53fd8673e5fca38ad5eaab69798ed29fbc","impliedFormat":1},{"version":"aa17748c522bd586f8712b1a308ea23af59c309b2fd278f6d4f406647c72e659","affectsGlobalScope":true,"impliedFormat":1},{"version":"cc69795d9954ee4ad57545b10c7bf1a7260d990231b1685c147ea71a6faa265c","impliedFormat":1},{"version":"8bc6c94ff4f2af1f4023b7bb2379b08d3d7dd80c698c9f0b07431ea16101f05f","impliedFormat":1},{"version":"1b61d259de5350f8b1e5db06290d31eaebebc6baafd5f79d314b5af9256d7153","impliedFormat":1},{"version":"57194e1f007f3f2cbef26fa299d4c6b21f4623a2eddc63dfeef79e38e187a36e","impliedFormat":1},{"version":"0f6666b58e9276ac3a38fdc80993d19208442d6027ab885580d93aec76b4ef00","impliedFormat":1},{"version":"05fd364b8ef02fb1e174fbac8b825bdb1e5a36a016997c8e421f5fab0a6da0a0","impliedFormat":1},{"version":"613b21ccdf3be6329d56e6caa13b258c842edf8377be7bc9f014ed14cdcfc308","affectsGlobalScope":true,"impliedFormat":1},{"version":"2d1319e6b5d0efd8c5eae07eb864a00102151e8b9afddd2d45db52e9aae002c4","affectsGlobalScope":true,"impliedFormat":1},{"version":"2db0dd3aaa2ed285950273ce96ae8a450b45423aa9da2d10e194570f1233fa6b","impliedFormat":1},{"version":"7394959e5a741b185456e1ef5d64599c36c60a323207450991e7a42e08911419","impliedFormat":1},{"version":"5929864ce17fba74232584d90cb721a89b7ad277220627cc97054ba15a98ea8f","impliedFormat":1},{"version":"24bd580b5743dc56402c440dc7f9a4f5d592ad7a419f25414d37a7bfe11e342b","impliedFormat":1},{"version":"25c8056edf4314820382a5fdb4bb7816999acdcb929c8f75e3f39473b87e85bc","impliedFormat":1},{"version":"c464d66b20788266e5353b48dc4aa6bc0dc4a707276df1e7152ab0c9ae21fad8","impliedFormat":1},{"version":"78d0d27c130d35c60b5e5566c9f1e5be77caf39804636bc1a40133919a949f21","impliedFormat":1},{"version":"c6fd2c5a395f2432786c9cb8deb870b9b0e8ff7e22c029954fabdd692bff6195","impliedFormat":1},{"version":"1d6e127068ea8e104a912e42fc0a110e2aa5a66a356a917a163e8cf9a65e4a75","impliedFormat":1},{"version":"5ded6427296cdf3b9542de4471d2aa8d3983671d4cac0f4bf9c637208d1ced43","impliedFormat":1},{"version":"6bdc71028db658243775263e93a7db2fd2abfce3ca569c3cca5aee6ed5eb186d","impliedFormat":1},{"version":"cadc8aced301244057c4e7e73fbcae534b0f5b12a37b150d80e5a45aa4bebcbd","impliedFormat":1},{"version":"385aab901643aa54e1c36f5ef3107913b10d1b5bb8cbcd933d4263b80a0d7f20","impliedFormat":1},{"version":"9670d44354bab9d9982eca21945686b5c24a3f893db73c0dae0fd74217a4c219","impliedFormat":1},{"version":"0b8a9268adaf4da35e7fa830c8981cfa22adbbe5b3f6f5ab91f6658899e657a7","impliedFormat":1},{"version":"11396ed8a44c02ab9798b7dca436009f866e8dae3c9c25e8c1fbc396880bf1bb","impliedFormat":1},{"version":"ba7bc87d01492633cb5a0e5da8a4a42a1c86270e7b3d2dea5d156828a84e4882","impliedFormat":1},{"version":"4893a895ea92c85345017a04ed427cbd6a1710453338df26881a6019432febdd","impliedFormat":1},{"version":"c21dc52e277bcfc75fac0436ccb75c204f9e1b3fa5e12729670910639f27343e","impliedFormat":1},{"version":"13f6f39e12b1518c6650bbb220c8985999020fe0f21d818e28f512b7771d00f9","impliedFormat":1},{"version":"9b5369969f6e7175740bf51223112ff209f94ba43ecd3bb09eefff9fd675624a","impliedFormat":1},{"version":"4fe9e626e7164748e8769bbf74b538e09607f07ed17c2f20af8d680ee49fc1da","impliedFormat":1},{"version":"24515859bc0b836719105bb6cc3d68255042a9f02a6022b3187948b204946bd2","impliedFormat":1},{"version":"ea0148f897b45a76544ae179784c95af1bd6721b8610af9ffa467a518a086a43","impliedFormat":1},{"version":"24c6a117721e606c9984335f71711877293a9651e44f59f3d21c1ea0856f9cc9","impliedFormat":1},{"version":"dd3273ead9fbde62a72949c97dbec2247ea08e0c6952e701a483d74ef92d6a17","impliedFormat":1},{"version":"405822be75ad3e4d162e07439bac80c6bcc6dbae1929e179cf467ec0b9ee4e2e","impliedFormat":1},{"version":"0db18c6e78ea846316c012478888f33c11ffadab9efd1cc8bcc12daded7a60b6","impliedFormat":1},{"version":"4d2b0eb911816f66abe4970898f97a2cfc902bcd743cbfa5017fad79f7ef90d8","impliedFormat":1},{"version":"bd0532fd6556073727d28da0edfd1736417a3f9f394877b6d5ef6ad88fba1d1a","impliedFormat":1},{"version":"89167d696a849fce5ca508032aabfe901c0868f833a8625d5a9c6e861ef935d2","impliedFormat":1},{"version":"e53a3c2a9f624d90f24bf4588aacd223e7bec1b9d0d479b68d2f4a9e6011147f","impliedFormat":1},{"version":"24b8685c62562f5d98615c5a0c1d05f297cf5065f15246edfe99e81ec4c0e011","impliedFormat":1},{"version":"93507c745e8f29090efb99399c3f77bec07db17acd75634249dc92f961573387","impliedFormat":1},{"version":"339dc5265ee5ed92e536a93a04c4ebbc2128f45eeec6ed29f379e0085283542c","impliedFormat":1},{"version":"4732aec92b20fb28c5fe9ad99521fb59974289ed1e45aecb282616202184064f","impliedFormat":1},{"version":"2e85db9e6fd73cfa3d7f28e0ab6b55417ea18931423bd47b409a96e4a169e8e6","impliedFormat":1},{"version":"c46e079fe54c76f95c67fb89081b3e399da2c7d109e7dca8e4b58d83e332e605","impliedFormat":1},{"version":"bf67d53d168abc1298888693338cb82854bdb2e69ef83f8a0092093c2d562107","impliedFormat":1},{"version":"4eedea23b8a843ec0fd51a384fb6b9fe1bc89198f713d0465c2c8392a9d51271","affectsGlobalScope":true,"impliedFormat":1},{"version":"3d77c73be94570813f8cadd1f05ebc3dc5e2e4fdefe4d340ca20cd018724ee36","impliedFormat":1},{"version":"d674383111e06b6741c4ad2db962131b5b0fa4d0294b998566c635e86195a453","affectsGlobalScope":true,"impliedFormat":1},{"version":"f3e58c4c18a031cbb17abec7a4ad0bd5ae9fc70c1f4ba1e7fb921ad87c504aca","impliedFormat":1},{"version":"a3e8bafb2af8e850c644f4be7f5156cf7d23b7bfdc3b786bd4d10ed40329649c","impliedFormat":1},{"version":"35ec8b6760fd7138bbf5809b84551e31028fb2ba7b6dc91d95d098bf212ca8b4","affectsGlobalScope":true,"impliedFormat":1},{"version":"a40826e8476694e90da94aa008283a7de50d1dafd37beada623863f1901cb7fb","impliedFormat":1},{"version":"c521f961c1606c94dc831992e659f426b6def6e2e6e327ee25d3c642eb393f95","affectsGlobalScope":true,"impliedFormat":1},{"version":"b0c0d1d13be149f790a75b381b413490f98558649428bb916fd2d71a3f47a134","impliedFormat":1},{"version":"3c884d9d9ec454bdf0d5a0b8465bf8297d2caa4d853851d92cc417ac6f30b969","impliedFormat":1},{"version":"5a369483ac4cfbdf0331c248deeb36140e6907db5e1daed241546b4a2055f82c","impliedFormat":1},{"version":"e8f5b5cc36615c17d330eaf8eebbc0d6bdd942c25991f96ef122f246f4ff722f","impliedFormat":1},{"version":"f0bd7e6d931657b59605c44112eaf8b980ba7f957a5051ed21cb93d978cf2f45","impliedFormat":1},{"version":"ee1ee365d88c4c6c0c0a5a5701d66ebc27ccd0bcfcfaa482c6e2e7fe7b98edf7","affectsGlobalScope":true,"impliedFormat":1},{"version":"9a30b7fefd7f8abbca4828d481c61c18e40fe5ff107e113b1c1fcd2c8dcf2743","affectsGlobalScope":true,"impliedFormat":1},{"version":"596572d40c1f13d8b57920d6d1a77c5ec6fe4952dc157f416f04a801cd3e2678","impliedFormat":1},{"version":"ad23fd126ff06e72728dd7bfc84326a8ca8cec2b9d2dac0193d42a777df0e7d8","impliedFormat":1},{"version":"a55fd4d49da86d2cc19de575beb1515184e55f5f3165e1482ff02fd99f900b5c","impliedFormat":1},{"version":"93bd413918fa921c8729cef45302b24d8b6c7855d72d5bf82d3972595ae8dcbf","impliedFormat":1},{"version":"4ff41188773cbf465807dd2f7059c7494cbee5115608efc297383832a1150c43","impliedFormat":1},{"version":"dccdf1677e531e33f8ac961a68bc537418c9a414797c1ea7e91307501cdc3f5e","impliedFormat":1},{"version":"7edec695cdb707c7146ac34c44ca364469c7ea504344b3206c686e79f61b61a2","affectsGlobalScope":true,"impliedFormat":1},{"version":"aba8aefa29914f531f49ba6b34212f7861022ad0b67a28c63a1d78264a8b1910","impliedFormat":1},{"version":"93f437e1398a4f06a984f441f7fa7a9f0535c04399619b5c22e0b87bdee182cb","impliedFormat":1},{"version":"d1b1295af3667779be43eb6d4fbaa342e656aa2c4b77a4ad3cf42ec55baeea00","impliedFormat":1},{"version":"70731d10d5311bd4cf710ef7f6539b62660f4b0bfdbb3f9fbe1d25fe6366a7fa","affectsGlobalScope":true,"impliedFormat":1},{"version":"a20f1e119615bf7632729fd89b6c0b5ffdc2df3b512d6304146294528e3ebe19","affectsGlobalScope":true,"impliedFormat":1},{"version":"9e043a1bc8fbf2a255bccf9bf27e0f1caf916c3b0518ea34aa72357c0afd42ec","impliedFormat":1},{"version":"137c2894e8f3e9672d401cc0a305dc7b1db7c69511cf6d3970fb53302f9eae09","impliedFormat":1},{"version":"dd9492e12a57068f08d70cb5eb5ceb39fa5bcf23be01af574270aeee95b982af","impliedFormat":1},{"version":"e432b0e3761ca9ba734bdd41e19a75fec1454ca8e9769bfdf8b31011854cf06a","impliedFormat":1},{"version":"f9d9d753d430ed050dc1bf2667a1bab711ccbb1c1507183d794cc195a5b085cc","impliedFormat":1},{"version":"9eece5e586312581ccd106d4853e861aaaa1a39f8e3ea672b8c3847eedd12f6e","impliedFormat":1},{"version":"78955c9259da94920609be3e589fc9253268b3fffa822e1e31d28ee2ce0b8a74","impliedFormat":1},{"version":"269929a24b2816343a178008ac9ae9248304d92a8ba8e233055e0ed6dbe6ef71","impliedFormat":1},{"version":"93452d394fdd1dc551ec62f5042366f011a00d342d36d50793b3529bfc9bd633","impliedFormat":1},{"version":"73aa178e8fb1449ef3666093d8dca25f96302a80ee45f8ff027df8e4792bf9fd","affectsGlobalScope":true,"impliedFormat":1},{"version":"1851a3b4db78664f83901bb9cac9e45e03a37bb5933cc5bf37e10bb7e91ab4eb","impliedFormat":1},{"version":"fdedf82878e4c744bc2a1c1e802ae407d63474da51f14a54babe039018e53d8f","affectsGlobalScope":true,"impliedFormat":1},{"version":"08353b04a3501d84fc8d7b49de99f6c1cc26026e6d9d697a18315f3bfe92ed03","affectsGlobalScope":true,"impliedFormat":1},{"version":"578d8bb6dcb2a1c03c4c3f8eb71abc9677e1a5c788b7f24848e3138ce17f3400","impliedFormat":1},{"version":"4f029899f9bae07e225c43aef893590541b2b43267383bf5e32e3a884d219ed5","impliedFormat":1},{"version":"ae56f65caf3be91108707bd8dfbccc2a57a91feb5daabf7165a06a945545ed26","impliedFormat":1},{"version":"a136d5de521da20f31631a0a96bf712370779d1c05b7015d7019a9b2a0446ca9","impliedFormat":1},{"version":"710ad93f8de29dc15e5892aa735e72348b62f40a6d1220f2849837d332f92885","affectsGlobalScope":true,"impliedFormat":1},{"version":"1f1da5d682cdb628890e4a8578fb9e8ab332e6a1a4b3a13fce08b7b4d45d192a","affectsGlobalScope":true,"impliedFormat":1},{"version":"efeedd8bbc5c0d53e760d8b120a010470722982e6ae14de8d1bcff66ebc2ae71","impliedFormat":1},{"version":"b718a94332858862943630649a310d6f8e9a09f86ae7215d8554e75bbbfd7817","impliedFormat":1},{"version":"d91a7d8b5655c42986f1bdfe2105c4408f472831c8f20cf11a8c3345b6b56c8c","impliedFormat":1},{"version":"616075a6ac578cf5a013ee12964188b4412823796ce0b202c6f1d2e4ca8480d7","affectsGlobalScope":true,"impliedFormat":1},{"version":"e8a979b8af001c9fc2e774e7809d233c8ca955a28756f52ee5dee88ccb0611d2","impliedFormat":1},{"version":"ab9b9a36e5284fd8d3bf2f7d5fcbc60052f25f27e4d20954782099282c60d23e","affectsGlobalScope":true,"impliedFormat":1},{"version":"483bb10b755f3572526fd76d9481221e8dc30568edcc1a9cc73479d8874bd16d","impliedFormat":1},{"version":"8caa5c86be1b793cd5f599e27ecb34252c41e011980f7d61ae4989a149ff6ccc","impliedFormat":1},{"version":"3609e455ffcba8176c8ce0aa57f8258fe10cf03987e27f1fab68f702b4426521","impliedFormat":1},{"version":"adb1c7864e5e872fe16beaa3a8c46879ec2af7b65417038d1d07117396d7b262","impliedFormat":1},{"version":"17ed71200119e86ccef2d96b73b02ce8854b76ad6bd21b5021d4269bec527b5f","impliedFormat":1},{"version":"5dbf2a502a7fcd85bfe753b585cfc6c9f60294570ee6a18084e574cf93be3fa0","impliedFormat":1},{"version":"bb7a61dd55dc4b9422d13da3a6bb9cc5e89be888ef23bbcf6558aa9726b89a1c","impliedFormat":1},{"version":"db6d2d9daad8a6d83f281af12ce4355a20b9a3e71b82b9f57cddcca0a8964a96","impliedFormat":1},{"version":"cfe4ef4710c3786b6e23dae7c086c70b4f4835a2e4d77b75d39f9046106e83d3","impliedFormat":1},{"version":"cbea99888785d49bb630dcbb1613c73727f2b5a2cf02e1abcaab7bcf8d6bf3c5","impliedFormat":1},{"version":"3989ccb24f2526f7e82cf54268e23ce9e1df5b9982f8acd099ddd4853c26babd","impliedFormat":1},{"version":"a86f82d646a739041d6702101afa82dcb935c416dd93cbca7fd754fd0282ce1f","impliedFormat":1},{"version":"2dad084c67e649f0f354739ec7df7c7df0779a28a4f55c97c6b6883ae850d1ce","impliedFormat":1},{"version":"fa5bbc7ab4130dd8cdc55ea294ec39f76f2bc507a0f75f4f873e38631a836ca7","impliedFormat":1},{"version":"df45ca1176e6ac211eae7ddf51336dc075c5314bc5c253651bae639defd5eec5","impliedFormat":1},{"version":"cf86de1054b843e484a3c9300d62fbc8c97e77f168bbffb131d560ca0474d4a8","impliedFormat":1},{"version":"196c960b12253fde69b204aa4fbf69470b26daf7a430855d7f94107a16495ab0","impliedFormat":1},{"version":"ee15ea5dd7a9fc9f5013832e5843031817a880bf0f24f37a29fd8337981aae07","impliedFormat":1},{"version":"bf24f6d35f7318e246010ffe9924395893c4e96d34324cde77151a73f078b9ad","impliedFormat":1},{"version":"805c5db07d4b131bede36cc2dbded64cc3c8e49594e53119f4442af183f97935","impliedFormat":1},{"version":"10595c7ff5094dd5b6a959ccb1c00e6a06441b4e10a87bc09c15f23755d34439","impliedFormat":1},{"version":"9620c1ff645afb4a9ab4044c85c26676f0a93e8c0e4b593aea03a89ccb47b6d0","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"a9af0e608929aaf9ce96bd7a7b99c9360636c31d73670e4af09a09950df97841","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"c86fe861cf1b4c46a0fb7d74dffe596cf679a2e5e8b1456881313170f092e3fa","impliedFormat":1},{"version":"08ed0b3f0166787f84a6606f80aa3b1388c7518d78912571b203817406e471da","impliedFormat":1},{"version":"47e5af2a841356a961f815e7c55d72554db0c11b4cba4d0caab91f8717846a94","impliedFormat":1},{"version":"65f43099ded6073336e697512d9b80f2d4fec3182b7b2316abf712e84104db00","impliedFormat":1},{"version":"f5f541902bf7ae0512a177295de9b6bcd6809ea38307a2c0a18bfca72212f368","impliedFormat":1},{"version":"b0decf4b6da3ebc52ea0c96095bdfaa8503acc4ac8e9081c5f2b0824835dd3bd","impliedFormat":1},{"version":"ca1b882a105a1972f82cc58e3be491e7d750a1eb074ffd13b198269f57ed9e1b","impliedFormat":1},{"version":"fc3e1c87b39e5ba1142f27ec089d1966da168c04a859a4f6aab64dceae162c2b","impliedFormat":1},{"version":"3b414b99a73171e1c4b7b7714e26b87d6c5cb03d200352da5342ab4088a54c85","impliedFormat":1},{"version":"61888522cec948102eba94d831c873200aa97d00d8989fdfd2a3e0ee75ec65a2","impliedFormat":1},{"version":"4e10622f89fea7b05dd9b52fb65e1e2b5cbd96d4cca3d9e1a60bb7f8a9cb86a1","impliedFormat":1},{"version":"74b2a5e5197bd0f2e0077a1ea7c07455bbea67b87b0869d9786d55104006784f","impliedFormat":1},{"version":"59bf32919de37809e101acffc120596a9e45fdbab1a99de5087f31fdc36e2f11","impliedFormat":1},{"version":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","impliedFormat":1},{"version":"b3aa6ede7dda2ee53ee78f257d5d6188f6ba75ac0a34a4b88be4ca93b869da07","impliedFormat":1},{"version":"c40c848daad198266370c1c72a7a8c3d18d2f50727c7859fcfefd3ff69a7f288","impliedFormat":1},{"version":"ac60bbee0d4235643cc52b57768b22de8c257c12bd8c2039860540cab1fa1d82","impliedFormat":1},{"version":"6428e6edd944ce6789afdf43f9376c1f2e4957eea34166177625aaff4c0da1a0","impliedFormat":1},{"version":"ada39cbb2748ab2873b7835c90c8d4620723aedf323550e8489f08220e477c7f","impliedFormat":1},{"version":"6e5f5cee603d67ee1ba6120815497909b73399842254fc1e77a0d5cdc51d8c9c","impliedFormat":1},{"version":"8dba67056cbb27628e9b9a1cba8e57036d359dceded0725c72a3abe4b6c79cd4","impliedFormat":1},{"version":"70f3814c457f54a7efe2d9ce9d2686de9250bb42eb7f4c539bd2280a42e52d33","impliedFormat":1},{"version":"154dd2e22e1e94d5bc4ff7726706bc0483760bae40506bdce780734f11f7ec47","impliedFormat":1},{"version":"ef61792acbfa8c27c9bd113f02731e66229f7d3a169e3c1993b508134f1a58e0","impliedFormat":1},{"version":"9c82171d836c47486074e4ca8e059735bf97b205e70b196535b5efd40cbe1bc5","impliedFormat":1},{"version":"15e3409b8397457d761d8d6f8c524795845c3aeb5dd0d4291ca0c54fec670b72","impliedFormat":1},{"version":"f6404e7837b96da3ea4d38c4f1a3812c96c9dcdf264e93d5bdb199f983a3ef4b","impliedFormat":1},{"version":"c5426dbfc1cf90532f66965a7aa8c1136a78d4d0f96d8180ecbfc11d7722f1a5","impliedFormat":1},{"version":"65a15fc47900787c0bd18b603afb98d33ede930bed1798fc984d5ebb78b26cf9","impliedFormat":1},{"version":"9d202701f6e0744adb6314d03d2eb8fc994798fc83d91b691b75b07626a69801","impliedFormat":1},{"version":"de9d2df7663e64e3a91bf495f315a7577e23ba088f2949d5ce9ec96f44fba37d","impliedFormat":1},{"version":"c7af78a2ea7cb1cd009cfb5bdb48cd0b03dad3b54f6da7aab615c2e9e9d570c5","impliedFormat":1},{"version":"1ee45496b5f8bdee6f7abc233355898e5bf9bd51255db65f5ff7ede617ca0027","impliedFormat":1},{"version":"8b8f00491431fe82f060dfe8c7f2180a9fb239f3d851527db909b83230e75882","affectsGlobalScope":true,"impliedFormat":1},{"version":"db01d18853469bcb5601b9fc9826931cc84cc1a1944b33cad76fd6f1e3d8c544","affectsGlobalScope":true,"impliedFormat":1},{"version":"dba114fb6a32b355a9cfc26ca2276834d72fe0e94cd2c3494005547025015369","impliedFormat":1},{"version":"903e299a28282fa7b714586e28409ed73c3b63f5365519776bf78e8cf173db36","affectsGlobalScope":true,"impliedFormat":1},{"version":"fa6c12a7c0f6b84d512f200690bfc74819e99efae69e4c95c4cd30f6884c526e","impliedFormat":1},{"version":"f1c32f9ce9c497da4dc215c3bc84b722ea02497d35f9134db3bb40a8d918b92b","impliedFormat":1},{"version":"b73c319af2cc3ef8f6421308a250f328836531ea3761823b4cabbd133047aefa","affectsGlobalScope":true,"impliedFormat":1},{"version":"e433b0337b8106909e7953015e8fa3f2d30797cea27141d1c5b135365bb975a6","impliedFormat":1},{"version":"dd3900b24a6a8745efeb7ad27629c0f8a626470ac229c1d73f1fe29d67e44dca","impliedFormat":1},{"version":"ddff7fc6edbdc5163a09e22bf8df7bef75f75369ebd7ecea95ba55c4386e2441","impliedFormat":1},{"version":"106c6025f1d99fd468fd8bf6e5bda724e11e5905a4076c5d29790b6c3745e50c","impliedFormat":1},{"version":"ec29be0737d39268696edcec4f5e97ce26f449fa9b7afc2f0f99a86def34a418","impliedFormat":1},{"version":"aeab39e8e0b1a3b250434c3b2bb8f4d17bbec2a9dbce5f77e8a83569d3d2cbc2","impliedFormat":1},{"version":"ec6cba1c02c675e4dd173251b156792e8d3b0c816af6d6ad93f1a55d674591aa","impliedFormat":1},{"version":"b620391fe8060cf9bedc176a4d01366e6574d7a71e0ac0ab344a4e76576fcbb8","impliedFormat":1},{"version":"d729408dfde75b451530bcae944cf89ee8277e2a9df04d1f62f2abfd8b03c1e1","impliedFormat":1},{"version":"e15d3c84d5077bb4a3adee4c791022967b764dc41cb8fa3cfa44d4379b2c95f5","impliedFormat":1},{"version":"5f58e28cd22e8fc1ac1b3bc6b431869f1e7d0b39e2c21fbf79b9fa5195a85980","impliedFormat":1},{"version":"e1fc1a1045db5aa09366be2b330e4ce391550041fc3e925f60998ca0b647aa97","impliedFormat":1},{"version":"63533978dcda286422670f6e184ac516805a365fb37a086eeff4309e812f1402","impliedFormat":1},{"version":"43ba4f2fa8c698f5c304d21a3ef596741e8e85a810b7c1f9b692653791d8d97a","impliedFormat":1},{"version":"31fb49ef3aa3d76f0beb644984e01eab0ea222372ea9b49bb6533be5722d756c","impliedFormat":1},{"version":"33cd131e1461157e3e06b06916b5176e7a8ec3fce15a5cfe145e56de744e07d2","impliedFormat":1},{"version":"889ef863f90f4917221703781d9723278db4122d75596b01c429f7c363562b86","impliedFormat":1},{"version":"3556cfbab7b43da96d15a442ddbb970e1f2fc97876d055b6555d86d7ac57dae5","impliedFormat":1},{"version":"437751e0352c6e924ddf30e90849f1d9eb00ca78c94d58d6a37202ec84eb8393","impliedFormat":1},{"version":"48e8af7fdb2677a44522fd185d8c87deff4d36ee701ea003c6c780b1407a1397","impliedFormat":1},{"version":"d11308de5a36c7015bb73adb5ad1c1bdaac2baede4cc831a05cf85efa3cc7f2f","impliedFormat":1},{"version":"38e4684c22ed9319beda6765bab332c724103d3a966c2e5e1c5a49cf7007845f","impliedFormat":1},{"version":"f9812cfc220ecf7557183379531fa409acd249b9e5b9a145d0d52b76c20862de","affectsGlobalScope":true,"impliedFormat":1},{"version":"0a403c4aeeb153bc0c1f11458d005f8e5a0af3535c4c93eedc6f7865a3593f8e","impliedFormat":1},{"version":"2e4f37ffe8862b14d8e24ae8763daaa8340c0df0b859d9a9733def0eee7562d9","impliedFormat":1},{"version":"13283350547389802aa35d9f2188effaeac805499169a06ef5cd77ce2a0bd63f","impliedFormat":1},{"version":"680793958f6a70a44c8d9ae7d46b7a385361c69ac29dcab3ed761edce1c14ab8","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"42c169fb8c2d42f4f668c624a9a11e719d5d07dacbebb63cbcf7ef365b0a75b3","impliedFormat":1},{"version":"913ddbba170240070bd5921b8f33ea780021bdf42fbdfcd4fcb2691b1884ddde","impliedFormat":1},{"version":"b4e6d416466999ff40d3fe5ceb95f7a8bfb7ac2262580287ac1a8391e5362431","impliedFormat":1},{"version":"5fe23bd829e6be57d41929ac374ee9551ccc3c44cee893167b7b5b77be708014","impliedFormat":1},{"version":"0a626484617019fcfbfc3c1bc1f9e84e2913f1adb73692aa9075817404fb41a1","impliedFormat":1},{"version":"438c7513b1df91dcef49b13cd7a1c4720f91a36e88c1df731661608b7c055f10","impliedFormat":1},{"version":"cf185cc4a9a6d397f416dd28cca95c227b29f0f27b160060a95c0e5e36cda865","impliedFormat":1},{"version":"0086f3e4ad898fd7ca56bb223098acfacf3fa065595182aaf0f6c4a6a95e6fbd","impliedFormat":1},{"version":"efaa078e392f9abda3ee8ade3f3762ab77f9c50b184e6883063a911742a4c96a","impliedFormat":1},{"version":"54a8bb487e1dc04591a280e7a673cdfb272c83f61e28d8a64cf1ac2e63c35c51","impliedFormat":1},{"version":"021a9498000497497fd693dd315325484c58a71b5929e2bbb91f419b04b24cea","impliedFormat":1},{"version":"9385cdc09850950bc9b59cca445a3ceb6fcca32b54e7b626e746912e489e535e","impliedFormat":1},{"version":"2894c56cad581928bb37607810af011764a2f511f575d28c9f4af0f2ef02d1ab","impliedFormat":1},{"version":"0a72186f94215d020cb386f7dca81d7495ab6c17066eb07d0f44a5bf33c1b21a","impliedFormat":1},{"version":"84124384abae2f6f66b7fbfc03862d0c2c0b71b826f7dbf42c8085d31f1d3f95","impliedFormat":1},{"version":"63a8e96f65a22604eae82737e409d1536e69a467bb738bec505f4f97cce9d878","impliedFormat":1},{"version":"3fd78152a7031315478f159c6a5872c712ece6f01212c78ea82aef21cb0726e2","impliedFormat":1},{"version":"250f9a1f11580b6b8a0a86835946f048eb605b3a596196741bfe72dc8f6c69cc","impliedFormat":1},{"version":"512fc15cca3a35b8dbbf6e23fe9d07e6f87ad03c895acffd3087ce09f352aad0","impliedFormat":1},{"version":"9a0946d15a005832e432ea0cd4da71b57797efb25b755cc07f32274296d62355","impliedFormat":1},{"version":"a52ff6c0a149e9f370372fc3c715d7f2beee1f3bab7980e271a7ab7d313ec677","impliedFormat":1},{"version":"fd933f824347f9edd919618a76cdb6a0c0085c538115d9a287fa0c7f59957ab3","impliedFormat":1},{"version":"6ac6715916fa75a1f7ebdfeacac09513b4d904b667d827b7535e84ff59679aff","impliedFormat":1},{"version":"6a1aa3e55bdc50503956c5cd09ae4cd72e3072692d742816f65c66ca14f4dfdd","impliedFormat":1},{"version":"ab75cfd9c4f93ffd601f7ca1753d6a9d953bbedfbd7a5b3f0436ac8a1de60dfa","impliedFormat":1},{"version":"f95180f03d827525ca4f990f49e17ec67198c316dd000afbe564655141f725cd","impliedFormat":1},{"version":"b73cbf0a72c8800cf8f96a9acfe94f3ad32ca71342a8908b8ae484d61113f647","impliedFormat":1},{"version":"bae6dd176832f6423966647382c0d7ba9e63f8c167522f09a982f086cd4e8b23","impliedFormat":1},{"version":"1364f64d2fb03bbb514edc42224abd576c064f89be6a990136774ecdd881a1da","impliedFormat":1},{"version":"c9958eb32126a3843deedda8c22fb97024aa5d6dd588b90af2d7f2bfac540f23","impliedFormat":1},{"version":"950fb67a59be4c2dbe69a5786292e60a5cb0e8612e0e223537784c731af55db1","impliedFormat":1},{"version":"e927c2c13c4eaf0a7f17e6022eee8519eb29ef42c4c13a31e81a611ab8c95577","impliedFormat":1},{"version":"07ca44e8d8288e69afdec7a31fa408ce6ab90d4f3d620006701d5544646da6aa","impliedFormat":1},{"version":"70246ad95ad8a22bdfe806cb5d383a26c0c6e58e7207ab9c431f1cb175aca657","impliedFormat":1},{"version":"f00f3aa5d64ff46e600648b55a79dcd1333458f7a10da2ed594d9f0a44b76d0b","impliedFormat":1},{"version":"772d8d5eb158b6c92412c03228bd9902ccb1457d7a705b8129814a5d1a6308fc","impliedFormat":1},{"version":"4e4475fba4ed93a72f167b061cd94a2e171b82695c56de9899275e880e06ba41","impliedFormat":1},{"version":"97c5f5d580ab2e4decd0a3135204050f9b97cd7908c5a8fbc041eadede79b2fa","impliedFormat":1},{"version":"c99a3a5f2215d5b9d735aa04cec6e61ed079d8c0263248e298ffe4604d4d0624","impliedFormat":1},{"version":"49b2375c586882c3ac7f57eba86680ff9742a8d8cb2fe25fe54d1b9673690d41","impliedFormat":1},{"version":"802e797bcab5663b2c9f63f51bdf67eff7c41bc64c0fd65e6da3e7941359e2f7","impliedFormat":1},{"version":"9ff1e8df66450af44161c1bfe34bc92c43074cfeec7a0a75f721830e9aabe379","impliedFormat":1},{"version":"3ecfccf916fea7c6c34394413b55eb70e817a73e39b4417d6573e523784e3f8e","impliedFormat":1},{"version":"1630192eac4188881201c64522cd3ef08209d9c4db0f9b5f0889b703dc6d936a","impliedFormat":1},{"version":"6459054aabb306821a043e02b89d54da508e3a6966601a41e71c166e4ea1474f","impliedFormat":1},{"version":"f416c9c3eee9d47ff49132c34f96b9180e50485d435d5748f0e8b72521d28d2e","impliedFormat":1},{"version":"05c97cddbaf99978f83d96de2d8af86aded9332592f08ce4a284d72d0952c391","impliedFormat":1},{"version":"14e5cdec6f8ae82dfd0694e64903a0a54abdfe37e1d966de3d4128362acbf35f","impliedFormat":1},{"version":"bbc183d2d69f4b59fd4dd8799ffdf4eb91173d1c4ad71cce91a3811c021bf80c","impliedFormat":1},{"version":"7b6ff760c8a240b40dab6e4419b989f06a5b782f4710d2967e67c695ef3e93c4","impliedFormat":1},{"version":"8dbc4134a4b3623fc476be5f36de35c40f2768e2e3d9ed437e0d5f1c4cd850f6","impliedFormat":1},{"version":"4e06330a84dec7287f7ebdd64978f41a9f70a668d3b5edc69d5d4a50b9b376bb","impliedFormat":1},{"version":"65bfa72967fbe9fc33353e1ac03f0480aa2e2ea346d61ff3ea997dfd850f641a","impliedFormat":1},{"version":"c06f0bb92d1a1a5a6c6e4b5389a5664d96d09c31673296cb7da5fe945d54d786","impliedFormat":1},{"version":"f974e4a06953682a2c15d5bd5114c0284d5abf8bc0fe4da25cb9159427b70072","impliedFormat":1},{"version":"872caaa31423f4345983d643e4649fb30f548e9883a334d6d1c5fff68ede22d4","impliedFormat":1},{"version":"94404c4a878fe291e7578a2a80264c6f18e9f1933fbb57e48f0eb368672e389c","impliedFormat":1},{"version":"5c1b7f03aa88be854bc15810bfd5bd5a1943c5a7620e1c53eddd2a013996343e","impliedFormat":1},{"version":"09dfc64fcd6a2785867f2368419859a6cc5a8d4e73cbe2538f205b1642eb0f51","impliedFormat":1},{"version":"bcf6f0a323653e72199105a9316d91463ad4744c546d1271310818b8cef7c608","impliedFormat":1},{"version":"01aa917531e116485beca44a14970834687b857757159769c16b228eb1e49c5f","impliedFormat":1},{"version":"351475f9c874c62f9b45b1f0dc7e2704e80dfd5f1af83a3a9f841f9dfe5b2912","impliedFormat":1},{"version":"ac457ad39e531b7649e7b40ee5847606eac64e236efd76c5d12db95bf4eacd17","impliedFormat":1},{"version":"187a6fdbdecb972510b7555f3caacb44b58415da8d5825d03a583c4b73fde4cf","impliedFormat":1},{"version":"d4c3250105a612202289b3a266bb7e323db144f6b9414f9dea85c531c098b811","impliedFormat":1},{"version":"95b444b8c311f2084f0fb51c616163f950fb2e35f4eaa07878f313a2d36c98a4","impliedFormat":1},{"version":"741067675daa6d4334a2dc80a4452ca3850e89d5852e330db7cb2b5f867173b1","impliedFormat":1},{"version":"f8acecec1114f11690956e007d920044799aefeb3cece9e7f4b1f8a1d542b2c9","impliedFormat":1},{"version":"178071ccd043967a58c5d1a032db0ddf9bd139e7920766b537d9783e88eb615e","impliedFormat":1},{"version":"3a17f09634c50cce884721f54fd9e7b98e03ac505889c560876291fcf8a09e90","impliedFormat":1},{"version":"32531dfbb0cdc4525296648f53b2b5c39b64282791e2a8c765712e49e6461046","impliedFormat":1},{"version":"0ce1b2237c1c3df49748d61568160d780d7b26693bd9feb3acb0744a152cd86d","impliedFormat":1},{"version":"e489985388e2c71d3542612685b4a7db326922b57ac880f299da7026a4e8a117","impliedFormat":1},{"version":"5cad4158616d7793296dd41e22e1257440910ea8d01c7b75045d4dfb20c5a41a","impliedFormat":1},{"version":"04d3aad777b6af5bd000bfc409907a159fe77e190b9d368da4ba649cdc28d39e","affectsGlobalScope":true,"impliedFormat":1},{"version":"74efc1d6523bd57eb159c18d805db4ead810626bc5bc7002a2c7f483044b2e0f","impliedFormat":1},{"version":"19252079538942a69be1645e153f7dbbc1ef56b4f983c633bf31fe26aeac32cd","impliedFormat":1},{"version":"bc11f3ac00ac060462597add171220aed628c393f2782ac75dd29ff1e0db871c","impliedFormat":1},{"version":"616775f16134fa9d01fc677ad3f76e68c051a056c22ab552c64cc281a9686790","impliedFormat":1},{"version":"65c24a8baa2cca1de069a0ba9fba82a173690f52d7e2d0f1f7542d59d5eb4db0","impliedFormat":1},{"version":"f9fe6af238339a0e5f7563acee3178f51db37f32a2e7c09f85273098cee7ec49","impliedFormat":1},{"version":"3b0b1d352b8d2e47f1c4df4fb0678702aee071155b12ef0185fce9eb4fa4af1e","impliedFormat":1},{"version":"77e71242e71ebf8528c5802993697878f0533db8f2299b4d36aa015bae08a79c","impliedFormat":1},{"version":"a344403e7a7384e0e7093942533d309194ad0a53eca2a3100c0b0ab4d3932773","impliedFormat":1},{"version":"b7fff2d004c5879cae335db8f954eb1d61242d9f2d28515e67902032723caeab","impliedFormat":1},{"version":"5f3dc10ae646f375776b4e028d2bed039a93eebbba105694d8b910feebbe8b9c","impliedFormat":1},{"version":"bb18bf4a61a17b4a6199eb3938ecfa4a59eb7c40843ad4a82b975ab6f7e3d925","impliedFormat":1},{"version":"4545c1a1ceca170d5d83452dd7c4994644c35cf676a671412601689d9a62da35","impliedFormat":1},{"version":"e9b6fc05f536dfddcdc65dbcf04e09391b1c968ab967382e48924f5cb90d88e1","impliedFormat":1},{"version":"a2d648d333cf67b9aeac5d81a1a379d563a8ffa91ddd61c6179f68de724260ff","impliedFormat":1},{"version":"2b664c3cc544d0e35276e1fb2d4989f7d4b4027ffc64da34ec83a6ccf2e5c528","impliedFormat":1},{"version":"a3f41ed1b4f2fc3049394b945a68ae4fdefd49fa1739c32f149d32c0545d67f5","impliedFormat":1},{"version":"3cd8f0464e0939b47bfccbb9bb474a6d87d57210e304029cd8eb59c63a81935d","impliedFormat":1},{"version":"47699512e6d8bebf7be488182427189f999affe3addc1c87c882d36b7f2d0b0e","impliedFormat":1},{"version":"3026abd48e5e312f2328629ede6e0f770d21c3cd32cee705c450e589d015ee09","impliedFormat":1},{"version":"8b140b398a6afbd17cc97c38aea5274b2f7f39b1ae5b62952cfe65bf493e3e75","impliedFormat":1},{"version":"7663d2c19ce5ef8288c790edba3d45af54e58c84f1b37b1249f6d49d962f3d91","impliedFormat":1},{"version":"5cce3b975cdb72b57ae7de745b3c5de5790781ee88bcb41ba142f07c0fa02e97","impliedFormat":1},{"version":"00bd6ebe607246b45296aa2b805bd6a58c859acecda154bfa91f5334d7c175c6","impliedFormat":1},{"version":"ad036a85efcd9e5b4f7dd5c1a7362c8478f9a3b6c3554654ca24a29aa850a9c5","impliedFormat":1},{"version":"fedebeae32c5cdd1a85b4e0504a01996e4a8adf3dfa72876920d3dd6e42978e7","impliedFormat":1},{"version":"0d28b974a7605c4eda20c943b3fa9ae16cb452c1666fc9b8c341b879992c7612","impliedFormat":1},{"version":"cdf21eee8007e339b1b9945abf4a7b44930b1d695cc528459e68a3adc39a622e","impliedFormat":1},{"version":"db036c56f79186da50af66511d37d9fe77fa6793381927292d17f81f787bb195","impliedFormat":1},{"version":"87ac2fb61e629e777f4d161dff534c2023ee15afd9cb3b1589b9b1f014e75c58","impliedFormat":1},{"version":"13c8b4348db91e2f7d694adc17e7438e6776bc506d5c8f5de9ad9989707fa3fe","impliedFormat":1},{"version":"3c1051617aa50b38e9efaabce25e10a5dd9b1f42e372ef0e8a674076a68742ed","impliedFormat":1},{"version":"07a3e20cdcb0f1182f452c0410606711fbea922ca76929a41aacb01104bc0d27","impliedFormat":1},{"version":"1de80059b8078ea5749941c9f863aa970b4735bdbb003be4925c853a8b6b4450","impliedFormat":1},{"version":"1d079c37fa53e3c21ed3fa214a27507bda9991f2a41458705b19ed8c2b61173d","impliedFormat":1},{"version":"4cd4b6b1279e9d744a3825cbd7757bbefe7f0708f3f1069179ad535f19e8ed2c","impliedFormat":1},{"version":"5835a6e0d7cd2738e56b671af0e561e7c1b4fb77751383672f4b009f4e161d70","impliedFormat":1},{"version":"c0eeaaa67c85c3bb6c52b629ebbfd3b2292dc67e8c0ffda2fc6cd2f78dc471e6","impliedFormat":1},{"version":"4b7f74b772140395e7af67c4841be1ab867c11b3b82a51b1aeb692822b76c872","impliedFormat":1},{"version":"27be6622e2922a1b412eb057faa854831b95db9db5035c3f6d4b677b902ab3b7","impliedFormat":1},{"version":"b95a6f019095dd1d48fd04965b50dfd63e5743a6e75478343c46d2582a5132bf","impliedFormat":99},{"version":"c2008605e78208cfa9cd70bd29856b72dda7ad89df5dc895920f8e10bcb9cd0a","impliedFormat":99},{"version":"b97cb5616d2ab82a98ec9ada7b9e9cabb1f5da880ec50ea2b8dc5baa4cbf3c16","impliedFormat":99},{"version":"d23df9ff06ae8bf1dcb7cc933e97ae7da418ac77749fecee758bb43a8d69f840","affectsGlobalScope":true,"impliedFormat":1},{"version":"040c71dde2c406f869ad2f41e8d4ce579cc60c8dbe5aa0dd8962ac943b846572","affectsGlobalScope":true,"impliedFormat":1},{"version":"3586f5ea3cc27083a17bd5c9059ede9421d587286d5a47f4341a4c2d00e4fa91","impliedFormat":1},{"version":"a6df929821e62f4719551f7955b9f42c0cd53c1370aec2dd322e24196a7dfe33","impliedFormat":1},{"version":"b789bf89eb19c777ed1e956dbad0925ca795701552d22e68fd130a032008b9f9","impliedFormat":1},"9dd9d642cdb87d4d5b3173217e0c45429b3e47a6f5cf5fb0ead6c644ec5fed01","21e9cb4ce9c26bbbac9e78dd5f285654db2c2fcdd82f454e1403e69458d66a8c","fcabca6b5788722c4025fc6172ab5979189816d08d27cf3dbd62d200b5702a61","f547105c921ba63a6ef20f1bac1a5e2041a6346cebd904a923692dba3be8b4e4","c3d71643dbb97b252bfc38ac3aaccd97d133d59e216f137f7cb0e2aa3bca991c","6d19f06351c9d30d9925360fd6630218a8499e4babb1d6f078c6ad0430f5a8b9","f8edb716b5460b0c173591b9da2f0f6d355558d30745fec93e09ce31cb1c68a4","6ccd370af8bda8333aeee33d0008d9a66abd5fe0aa526a6c475c3c7c36e577ad","0cc1a70e1ba70ed7c75ae67a6f2050368872931d6c2a1616157fb88f2f2caaf3","e548bd255cf05100b6e8563919a83a220190c4473758ac6d52c6e4c3fdfecc8d","2de49ff199c051721182c0da82bd83e25060f253a9df21922ff1f3a372549569","ab37bb3418e631d1f96052ab18ccc517843492a3306921e94fff6d2ca67c5cc8","81766d90187c30bf7b0a269071179ca8a02df45e2c599cbcc153c83e53d3d57f","238b81f90ca258cfc83cd2160f56568f358fed46e3daa70d2bebed547969d907","a3a5e6e544bc66bb32e6be3258becf87b985c4b41274bc904379197a966b5f9b","8cfe59a62b491a81cf34dbc7ad6f0895beef71ac4ee53719d8c9bd3948e81f7d","37e4f1bd1c71a1c8478d4fe7676207452bbb2ed28fc44a4dba8173749565af81","eeba468ce00d565a870b61fc5aa4ff86d3a893d602026b6745e2e8ddc3a4af08"],"root":[[395,412]],"options":{"allowJs":true,"esModuleInterop":true,"jsx":1,"module":99,"skipLibCheck":true,"strict":true,"target":9},"referencedMap":[[407,1],[408,2],[409,3],[405,4],[410,5],[411,6],[406,7],[412,8],[399,9],[398,9],[400,10],[401,10],[396,11],[402,12],[403,10],[397,10],[404,10],[395,13],[348,10],[88,14],[89,14],[128,15],[129,16],[130,17],[131,18],[132,19],[133,20],[134,21],[135,22],[136,23],[137,24],[138,24],[140,25],[139,26],[141,27],[142,28],[143,29],[127,30],[178,10],[144,31],[145,32],[146,33],[147,34],[148,35],[149,36],[150,37],[151,38],[152,39],[153,40],[154,41],[155,42],[156,43],[157,43],[158,44],[159,10],[160,45],[162,46],[161,47],[163,48],[164,49],[165,50],[166,51],[167,52],[168,53],[169,54],[87,55],[86,10],[179,56],[170,57],[171,58],[172,59],[173,60],[174,61],[175,62],[176,63],[177,64],[78,10],[184,65],[185,66],[183,9],[181,67],[182,68],[76,10],[79,69],[272,9],[77,10],[85,70],[351,71],[355,72],[357,73],[205,74],[219,75],[322,76],[251,10],[325,77],[287,78],[295,79],[323,80],[206,81],[250,10],[252,82],[324,83],[226,84],[207,85],[231,84],[220,84],[190,84],[278,86],[279,87],[195,10],[275,88],[280,89],[366,90],[273,89],[367,91],[257,10],[276,92],[379,93],[378,94],[282,89],[377,10],[375,10],[376,95],[277,9],[264,96],[265,97],[274,98],[290,99],[291,100],[281,101],[259,102],[260,103],[370,104],[373,105],[238,106],[237,107],[236,108],[382,9],[235,109],[211,10],[385,10],[388,10],[387,9],[389,110],[186,10],[316,10],[218,111],[188,112],[339,10],[340,10],[342,10],[345,113],[341,10],[343,114],[344,114],[204,10],[217,10],[350,115],[358,116],[362,117],[200,118],[267,119],[266,10],[258,102],[286,120],[284,121],[283,10],[285,10],[289,122],[262,123],[199,124],[224,125],[313,126],[191,127],[198,128],[187,76],[327,129],[337,130],[326,10],[336,131],[225,10],[209,132],[304,133],[303,10],[310,134],[312,135],[305,136],[309,137],[311,134],[308,136],[307,134],[306,136],[247,138],[232,138],[298,139],[233,139],[193,140],[192,10],[302,141],[301,142],[300,143],[299,144],[194,145],[271,146],[288,147],[270,148],[294,149],[296,150],[293,148],[227,145],[180,10],[314,151],[253,152],[335,153],[256,154],[330,155],[197,10],[331,156],[333,157],[334,158],[317,10],[329,127],[229,159],[315,160],[338,161],[201,10],[203,10],[208,162],[297,163],[196,164],[202,10],[255,165],[254,166],[210,167],[263,168],[261,169],[212,170],[214,171],[386,10],[213,172],[215,173],[353,10],[352,10],[354,10],[384,10],[216,174],[269,9],[84,10],[292,175],[239,10],[249,176],[228,10],[360,9],[369,177],[246,9],[364,89],[245,178],[347,179],[244,177],[189,10],[371,180],[242,9],[243,9],[234,10],[248,10],[241,181],[240,182],[230,183],[223,101],[332,10],[222,184],[221,10],[356,10],[268,9],[349,185],[75,10],[83,186],[80,9],[81,10],[82,10],[328,187],[321,188],[320,10],[319,189],[318,10],[359,190],[361,191],[363,192],[365,193],[368,194],[394,195],[372,195],[393,196],[374,197],[380,198],[381,199],[383,200],[390,201],[392,10],[391,202],[346,203],[73,10],[74,10],[12,10],[13,10],[15,10],[14,10],[2,10],[16,10],[17,10],[18,10],[19,10],[20,10],[21,10],[22,10],[23,10],[3,10],[24,10],[4,10],[25,10],[29,10],[26,10],[27,10],[28,10],[30,10],[31,10],[32,10],[5,10],[33,10],[34,10],[35,10],[36,10],[6,10],[40,10],[37,10],[38,10],[39,10],[41,10],[7,10],[42,10],[47,10],[48,10],[43,10],[44,10],[45,10],[46,10],[8,10],[52,10],[49,10],[50,10],[51,10],[53,10],[9,10],[54,10],[55,10],[56,10],[59,10],[57,10],[58,10],[60,10],[61,10],[10,10],[62,10],[1,10],[63,10],[64,10],[11,10],[69,10],[66,10],[65,10],[72,10],[70,10],[68,10],[71,10],[67,10],[105,204],[115,205],[104,204],[125,206],[96,207],[95,208],[124,202],[118,209],[123,210],[98,211],[112,212],[97,213],[121,214],[93,215],[92,202],[122,216],[94,217],[99,218],[100,10],[103,218],[90,10],[126,219],[116,220],[107,221],[108,222],[110,223],[106,224],[109,225],[119,202],[101,226],[102,227],[111,228],[91,229],[114,220],[113,218],[117,10],[120,230]],"affectedFilesPendingEmit":[407,408,409,405,410,411,406,412,399,398,400,401,396,402,403,397,404],"version":"5.6.3"}
\ No newline at end of file
diff --git a/db/migrations/002_data_orders.sql b/db/migrations/002_data_orders.sql
new file mode 100644
index 0000000..6726bb3
--- /dev/null
+++ b/db/migrations/002_data_orders.sql
@@ -0,0 +1,57 @@
+-- Doctor data marketplace: mirror of Counsel & Bar's data_orders for the
+-- LA County physician directory. Buyers pick specialties + cities, get a CSV.
+-- Two purchase kinds:
+-- 'one_time' — single CSV download, snapshot at purchase time
+-- 'subscription'— monthly recurring, unlimited downloads while active
+--
+-- Distinct from any future per-doctor upgrade-listing flow (different SKU,
+-- different buyer). Stripe is the source of truth on payment status; download_token
+-- is the gate (random 32-byte hex, stored as plain text for direct lookup).
+
+CREATE TABLE IF NOT EXISTS data_orders (
+ id BIGSERIAL PRIMARY KEY,
+ kind TEXT NOT NULL CHECK (kind IN ('one_time','subscription')),
+
+ -- buyer
+ email TEXT NOT NULL,
+ full_name TEXT,
+ company TEXT,
+ phone TEXT,
+
+ -- product spec — both filter dimensions persist on the row so we can
+ -- regenerate the CSV later (snapshot count) or audit what was sold.
+ cities TEXT[] NOT NULL DEFAULT '{}'::TEXT[],
+ specialties TEXT[] NOT NULL DEFAULT '{}'::TEXT[],
+ doctor_count INT,
+ amount_cents INT NOT NULL,
+
+ -- status
+ status TEXT NOT NULL DEFAULT 'pending_payment'
+ CHECK (status IN ('pending_payment','paid','refunded','cancelled','sub_cancelled')),
+
+ -- stripe
+ stripe_session_id TEXT,
+ stripe_customer_id TEXT,
+ stripe_subscription_id TEXT,
+ payment_link TEXT,
+
+ -- delivery
+ download_token TEXT NOT NULL UNIQUE, -- random 32-byte hex
+ downloads_count INT NOT NULL DEFAULT 0,
+ last_downloaded_at TIMESTAMPTZ,
+
+ -- timestamps
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ paid_at TIMESTAMPTZ,
+ cancelled_at TIMESTAMPTZ,
+
+ -- audit
+ ip TEXT,
+ user_agent TEXT
+);
+
+CREATE INDEX IF NOT EXISTS idx_data_orders_email ON data_orders (LOWER(email));
+CREATE INDEX IF NOT EXISTS idx_data_orders_status ON data_orders (status);
+CREATE INDEX IF NOT EXISTS idx_data_orders_created ON data_orders (created_at DESC);
+
+INSERT INTO schema_migrations(filename) VALUES ('002_data_orders.sql') ON CONFLICT DO NOTHING;
diff --git a/db/migrations/003_users_auth.sql b/db/migrations/003_users_auth.sql
new file mode 100644
index 0000000..b5c4bd7
--- /dev/null
+++ b/db/migrations/003_users_auth.sql
@@ -0,0 +1,104 @@
+-- Phase 1 — Auth, users, doctor claims, subscriptions, claim_status
+-- Apply with: psql -d doctor_professional_directory -f db/migrations/003_users_auth.sql
+
+BEGIN;
+
+-- ─── users ─────────────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS users (
+ id bigserial PRIMARY KEY,
+ email citext NOT NULL UNIQUE,
+ email_verified_at timestamptz,
+ google_sub text UNIQUE,
+ display_name text,
+ avatar_url text,
+ role text NOT NULL DEFAULT 'patient'
+ CHECK (role IN ('guest','patient','doctor','admin')),
+ tier text NOT NULL DEFAULT 'free'
+ CHECK (tier IN ('free','paid')),
+ comments_disabled boolean NOT NULL DEFAULT false,
+ claimed_professional_id bigint REFERENCES professionals(id) ON DELETE SET NULL,
+ claimed_organization_id bigint REFERENCES organizations(id) ON DELETE SET NULL,
+ stripe_customer_id text UNIQUE,
+ last_login_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ deleted_at timestamptz
+);
+CREATE INDEX IF NOT EXISTS idx_users_role_tier ON users (role, tier);
+CREATE INDEX IF NOT EXISTS idx_users_claimed_pro ON users (claimed_professional_id) WHERE claimed_professional_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_users_claimed_org ON users (claimed_organization_id) WHERE claimed_organization_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_users_deleted ON users (deleted_at) WHERE deleted_at IS NOT NULL;
+
+-- ─── doctor_claims ─────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS doctor_claims (
+ id bigserial PRIMARY KEY,
+ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ target_professional_id bigint REFERENCES professionals(id) ON DELETE CASCADE,
+ target_organization_id bigint REFERENCES organizations(id) ON DELETE CASCADE,
+ npi_provided varchar(10),
+ license_provided text,
+ email_verification_token text NOT NULL,
+ email_verified_at timestamptz,
+ status text NOT NULL DEFAULT 'pending'
+ CHECK (status IN ('pending','approved','rejected','expired')),
+ status_reason text,
+ decided_at timestamptz,
+ decided_by_user_id bigint REFERENCES users(id),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT either_target CHECK (
+ (target_professional_id IS NOT NULL AND target_organization_id IS NULL) OR
+ (target_professional_id IS NULL AND target_organization_id IS NOT NULL)
+ )
+);
+CREATE INDEX IF NOT EXISTS idx_claims_status ON doctor_claims (status);
+CREATE INDEX IF NOT EXISTS idx_claims_user ON doctor_claims (user_id);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_claims_one_pending_pro
+ ON doctor_claims (target_professional_id) WHERE status = 'pending' AND target_professional_id IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS idx_claims_one_pending_org
+ ON doctor_claims (target_organization_id) WHERE status = 'pending' AND target_organization_id IS NOT NULL;
+
+-- ─── subscriptions ─────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS subscriptions (
+ id bigserial PRIMARY KEY,
+ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ stripe_subscription_id text NOT NULL UNIQUE,
+ stripe_price_id text NOT NULL,
+ plan text NOT NULL, -- doctor_pro_monthly | patient_plus_monthly
+ status text NOT NULL, -- active|past_due|canceled|unpaid|trialing
+ current_period_end timestamptz,
+ cancel_at_period_end boolean NOT NULL DEFAULT false,
+ started_at timestamptz NOT NULL DEFAULT now(),
+ ended_at timestamptz,
+ raw_json jsonb
+);
+CREATE INDEX IF NOT EXISTS idx_subs_user ON subscriptions (user_id);
+CREATE INDEX IF NOT EXISTS idx_subs_status ON subscriptions (status);
+
+-- ─── express-session table (connect-pg-simple expected schema) ────────────
+CREATE TABLE IF NOT EXISTS user_sessions (
+ sid varchar NOT NULL COLLATE "default" PRIMARY KEY,
+ sess json NOT NULL,
+ expire timestamp(6) NOT NULL
+) WITH (OIDS=FALSE);
+CREATE INDEX IF NOT EXISTS IDX_user_sessions_expire ON user_sessions (expire);
+
+-- ─── claim_status on existing tables ───────────────────────────────────────
+ALTER TABLE professionals
+ ADD COLUMN IF NOT EXISTS claim_status text NOT NULL DEFAULT 'unclaimed'
+ CHECK (claim_status IN ('unclaimed','claimed','verified'));
+ALTER TABLE organizations
+ ADD COLUMN IF NOT EXISTS claim_status text NOT NULL DEFAULT 'unclaimed'
+ CHECK (claim_status IN ('unclaimed','claimed','verified'));
+CREATE INDEX IF NOT EXISTS idx_pros_claim ON professionals (claim_status);
+CREATE INDEX IF NOT EXISTS idx_orgs_claim ON organizations (claim_status);
+
+-- ─── updated_at trigger function (idempotent) ─────────────────────────────
+CREATE OR REPLACE FUNCTION trigger_set_timestamp() RETURNS trigger AS $$
+BEGIN NEW.updated_at = now(); RETURN NEW; END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS users_set_updated_at ON users;
+CREATE TRIGGER users_set_updated_at BEFORE UPDATE ON users
+ FOR EACH ROW EXECUTE FUNCTION trigger_set_timestamp();
+
+COMMIT;
diff --git a/db/migrations/004_reviews.sql b/db/migrations/004_reviews.sql
new file mode 100644
index 0000000..a9f0e87
--- /dev/null
+++ b/db/migrations/004_reviews.sql
@@ -0,0 +1,103 @@
+-- Phase 3 — reviews, ratings, aggregation cache.
+-- Apply: psql -d doctor_professional_directory -f db/migrations/004_reviews.sql
+
+BEGIN;
+
+-- ─── reviews ───────────────────────────────────────────────────────────────
+-- One review per (reviewer, target). Patients post under role='patient'+tier='free'+;
+-- doctors can also post under specific guardrails (no self-review). Reddit/Yelp/HG
+-- seeded reviews use source != 'user' and have no reviewer_user_id.
+CREATE TABLE IF NOT EXISTS reviews (
+ id bigserial PRIMARY KEY,
+ target_professional_id bigint REFERENCES professionals(id) ON DELETE CASCADE,
+ target_organization_id bigint REFERENCES organizations(id) ON DELETE CASCADE,
+ reviewer_user_id bigint REFERENCES users(id) ON DELETE SET NULL,
+ reviewer_display text, -- snapshot for seeded reviews
+ service_score smallint CHECK (service_score IS NULL OR service_score BETWEEN 1 AND 5),
+ price_score smallint CHECK (price_score IS NULL OR price_score BETWEEN 1 AND 5),
+ quality_score smallint CHECK (quality_score IS NULL OR quality_score BETWEEN 1 AND 5),
+ overall_score smallint CHECK (overall_score IS NULL OR overall_score BETWEEN 1 AND 5),
+ title text,
+ body text NOT NULL,
+ source text NOT NULL DEFAULT 'user'
+ CHECK (source IN ('user','reddit','yelp_seed','healthgrades_seed','nextdoor')),
+ source_url text,
+ source_posted_at timestamptz,
+ suppressed_by_target boolean NOT NULL DEFAULT false, -- patient claims "not me" / opts out their profile
+ hidden_by_admin boolean NOT NULL DEFAULT false,
+ hidden_reason text,
+ go_live_at timestamptz, -- 24h delay before public view (anti-brigading)
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT either_target_review CHECK (
+ (target_professional_id IS NOT NULL AND target_organization_id IS NULL) OR
+ (target_professional_id IS NULL AND target_organization_id IS NOT NULL)
+ )
+);
+CREATE INDEX IF NOT EXISTS idx_reviews_target_pro ON reviews (target_professional_id) WHERE target_professional_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_reviews_target_org ON reviews (target_organization_id) WHERE target_organization_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_reviews_source ON reviews (source);
+CREATE INDEX IF NOT EXISTS idx_reviews_reviewer ON reviews (reviewer_user_id) WHERE reviewer_user_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_reviews_visible ON reviews (target_professional_id, target_organization_id)
+ WHERE suppressed_by_target = false AND hidden_by_admin = false;
+-- Anti-double-review (only one user-source review per reviewer/target pair).
+CREATE UNIQUE INDEX IF NOT EXISTS idx_reviews_one_per_user_pro
+ ON reviews (reviewer_user_id, target_professional_id)
+ WHERE source='user' AND reviewer_user_id IS NOT NULL AND target_professional_id IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS idx_reviews_one_per_user_org
+ ON reviews (reviewer_user_id, target_organization_id)
+ WHERE source='user' AND reviewer_user_id IS NOT NULL AND target_organization_id IS NOT NULL;
+-- Dedup of seeded reviews by source URL.
+CREATE UNIQUE INDEX IF NOT EXISTS idx_reviews_source_url
+ ON reviews (source_url) WHERE source_url IS NOT NULL;
+
+DROP TRIGGER IF EXISTS reviews_set_updated_at ON reviews;
+CREATE TRIGGER reviews_set_updated_at BEFORE UPDATE ON reviews
+ FOR EACH ROW EXECUTE FUNCTION trigger_set_timestamp();
+
+-- ─── review_responses ─────────────────────────────────────────────────────
+-- Doctor's official reply on a review. One per review.
+CREATE TABLE IF NOT EXISTS review_responses (
+ id bigserial PRIMARY KEY,
+ review_id bigint NOT NULL UNIQUE REFERENCES reviews(id) ON DELETE CASCADE,
+ author_user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ body text NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+DROP TRIGGER IF EXISTS review_responses_set_updated_at ON review_responses;
+CREATE TRIGGER review_responses_set_updated_at BEFORE UPDATE ON review_responses
+ FOR EACH ROW EXECUTE FUNCTION trigger_set_timestamp();
+
+-- ─── review_flags (community moderation) ──────────────────────────────────
+CREATE TABLE IF NOT EXISTS review_flags (
+ id bigserial PRIMARY KEY,
+ review_id bigint NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
+ flagger_user_id bigint REFERENCES users(id) ON DELETE SET NULL,
+ reason text NOT NULL CHECK (reason IN ('spam','fake','off_topic','medical_advice','personal_attack','other')),
+ notes text,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_review_flags_review ON review_flags (review_id);
+
+-- ─── aggregated_ratings (denormalized) ────────────────────────────────────
+-- Recomputed nightly by scripts/aggregate-ratings.js. Honors suppression + hidden.
+CREATE TABLE IF NOT EXISTS aggregated_ratings (
+ id bigserial PRIMARY KEY,
+ target_professional_id bigint UNIQUE REFERENCES professionals(id) ON DELETE CASCADE,
+ target_organization_id bigint UNIQUE REFERENCES organizations(id) ON DELETE CASCADE,
+ service_avg numeric(3,2),
+ price_avg numeric(3,2),
+ quality_avg numeric(3,2),
+ overall_avg numeric(3,2),
+ n_reviews integer NOT NULL DEFAULT 0,
+ n_user_reviews integer NOT NULL DEFAULT 0,
+ n_seeded_reviews integer NOT NULL DEFAULT 0,
+ last_recomputed_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT either_target_agg CHECK (
+ (target_professional_id IS NOT NULL AND target_organization_id IS NULL) OR
+ (target_professional_id IS NULL AND target_organization_id IS NOT NULL)
+ )
+);
+
+COMMIT;
diff --git a/db/migrations/005_community.sql b/db/migrations/005_community.sql
new file mode 100644
index 0000000..5ac45b2
--- /dev/null
+++ b/db/migrations/005_community.sql
@@ -0,0 +1,103 @@
+-- Phase 4 — community threads + private DMs.
+-- Apply: psql -d doctor_professional_directory -f db/migrations/005_community.sql
+
+BEGIN;
+
+-- ─── threads ──────────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS threads (
+ id bigserial PRIMARY KEY,
+ title text NOT NULL,
+ body text,
+ city text,
+ zip text,
+ organization_id bigint REFERENCES organizations(id) ON DELETE SET NULL, -- optional anchor to a practice
+ category text NOT NULL DEFAULT 'general'
+ CHECK (category IN ('general','recommendations','specialty','insurance','pediatrics','eldercare','mental_health','dental','optometry','question','rant')),
+ created_by_user_id bigint REFERENCES users(id) ON DELETE SET NULL,
+ hidden_by_admin boolean NOT NULL DEFAULT false,
+ pinned boolean NOT NULL DEFAULT false,
+ reply_count integer NOT NULL DEFAULT 0,
+ last_post_at timestamptz NOT NULL DEFAULT now(),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_threads_visible ON threads (last_post_at DESC) WHERE hidden_by_admin = false;
+CREATE INDEX IF NOT EXISTS idx_threads_city ON threads (city) WHERE city IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_threads_org ON threads (organization_id) WHERE organization_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_threads_cat ON threads (category);
+
+DROP TRIGGER IF EXISTS threads_set_updated_at ON threads;
+CREATE TRIGGER threads_set_updated_at BEFORE UPDATE ON threads
+ FOR EACH ROW EXECUTE FUNCTION trigger_set_timestamp();
+
+-- ─── thread_posts ─────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS thread_posts (
+ id bigserial PRIMARY KEY,
+ thread_id bigint NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
+ author_user_id bigint REFERENCES users(id) ON DELETE SET NULL,
+ body text NOT NULL,
+ hidden_by_admin boolean NOT NULL DEFAULT false,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_posts_thread ON thread_posts (thread_id, created_at);
+CREATE INDEX IF NOT EXISTS idx_posts_author ON thread_posts (author_user_id) WHERE author_user_id IS NOT NULL;
+
+DROP TRIGGER IF EXISTS posts_set_updated_at ON thread_posts;
+CREATE TRIGGER posts_set_updated_at BEFORE UPDATE ON thread_posts
+ FOR EACH ROW EXECUTE FUNCTION trigger_set_timestamp();
+
+-- Bump thread.last_post_at + reply_count on every new post.
+CREATE OR REPLACE FUNCTION trigger_bump_thread_on_post() RETURNS trigger AS $$
+BEGIN
+ UPDATE threads
+ SET last_post_at = NEW.created_at,
+ reply_count = reply_count + 1,
+ updated_at = now()
+ WHERE id = NEW.thread_id;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS posts_bump_thread ON thread_posts;
+CREATE TRIGGER posts_bump_thread AFTER INSERT ON thread_posts
+ FOR EACH ROW EXECUTE FUNCTION trigger_bump_thread_on_post();
+
+-- ─── dm_pairs ─────────────────────────────────────────────────────────────
+-- Canonical pair representation: user_a_id < user_b_id (always).
+CREATE TABLE IF NOT EXISTS dm_pairs (
+ id bigserial PRIMARY KEY,
+ user_a_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ user_b_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ last_message_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CHECK (user_a_id < user_b_id),
+ UNIQUE (user_a_id, user_b_id)
+);
+CREATE INDEX IF NOT EXISTS idx_dmp_a ON dm_pairs (user_a_id, last_message_at DESC NULLS LAST);
+CREATE INDEX IF NOT EXISTS idx_dmp_b ON dm_pairs (user_b_id, last_message_at DESC NULLS LAST);
+
+-- ─── messages ─────────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS messages (
+ id bigserial PRIMARY KEY,
+ dm_pair_id bigint NOT NULL REFERENCES dm_pairs(id) ON DELETE CASCADE,
+ author_user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ body text NOT NULL,
+ sent_at timestamptz NOT NULL DEFAULT now(),
+ read_at timestamptz
+);
+CREATE INDEX IF NOT EXISTS idx_msgs_pair ON messages (dm_pair_id, sent_at);
+CREATE INDEX IF NOT EXISTS idx_msgs_unread ON messages (dm_pair_id) WHERE read_at IS NULL;
+
+CREATE OR REPLACE FUNCTION trigger_bump_pair_on_msg() RETURNS trigger AS $$
+BEGIN
+ UPDATE dm_pairs SET last_message_at = NEW.sent_at WHERE id = NEW.dm_pair_id;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS msgs_bump_pair ON messages;
+CREATE TRIGGER msgs_bump_pair AFTER INSERT ON messages
+ FOR EACH ROW EXECUTE FUNCTION trigger_bump_pair_on_msg();
+
+COMMIT;
diff --git a/db/migrations/006_front_page_crawl.sql b/db/migrations/006_front_page_crawl.sql
new file mode 100644
index 0000000..9beed5c
--- /dev/null
+++ b/db/migrations/006_front_page_crawl.sql
@@ -0,0 +1,25 @@
+-- 006_front_page_crawl.sql
+-- Front-page HTML capture + ad/social-tracking enrichment.
+-- Mirrors lawyer-directory's front_page_audits + business_enrichment, adapted
+-- to organizations (organization_id, not business_id).
+
+CREATE TABLE IF NOT EXISTS public.front_page_audits (
+ organization_id BIGINT REFERENCES organizations(id),
+ url TEXT NOT NULL,
+ audited_at TIMESTAMPTZ DEFAULT now(),
+ raw_html_path TEXT,
+ http_status INT,
+ fetch_ms INT,
+ PRIMARY KEY (organization_id, audited_at)
+);
+CREATE INDEX IF NOT EXISTS fpa_org_id_idx ON front_page_audits(organization_id);
+CREATE INDEX IF NOT EXISTS fpa_html_idx
+ ON front_page_audits(raw_html_path)
+ WHERE raw_html_path IS NOT NULL;
+
+CREATE TABLE IF NOT EXISTS public.org_enrichment (
+ organization_id BIGINT PRIMARY KEY REFERENCES organizations(id),
+ ad_signals JSONB,
+ signals_updated_at TIMESTAMPTZ,
+ enriched_at TIMESTAMPTZ DEFAULT now()
+);
diff --git a/docs/phase-0-domain-research.md b/docs/phase-0-domain-research.md
new file mode 100644
index 0000000..9961ac0
--- /dev/null
+++ b/docs/phase-0-domain-research.md
@@ -0,0 +1,53 @@
+# Phase 0 — Domain research results (2026-05-01)
+
+## Existing inventory pass
+
+Scanned Steve's 270-domain portfolio for healthcare-shaped names. **Zero strong fits** — the only matches were `hospitality*` (Designer Wallcoverings reseller portfolios — wrong domain for healthcare) and `healthcarewallpaper.com` / `fireratedwallcovering.com` / `hospitalitywallcoverings.com` (all DW-related, wrong fit). **None of the 270 owned domains works for the new platform.**
+
+## Fresh availability scan
+
+Ran `dig +short NS` and `whois` against 40+ candidates across `.com`, `.la`, and `.healthcare`. Most strong `.com` names are parked-for-sale at Afternic/HugeDomains/BrandBucket (premium pricing $1K-10K typical). `.la` TLD ($25-40/yr retail) yielded several fresh, brand-clean names.
+
+## Confirmed AVAILABLE
+
+| Domain | Length | Annual cost (Porkbun ballpark) | Notes |
+|---|---|---|---|
+| **whichdoctor.la** | 14 | ~$30/yr | Punchy. Query-shaped — "which doctor" prompts intent. .la doubles as Los Angeles brand. |
+| **healthcity.la** | 13 | ~$30/yr | Broadest scope — covers nurses, RCFEs, home care, not just MDs. Positions LA as "the city." Room to grow. |
+| **carescore.la** | 12 | ~$30/yr | Rating-first brand. Cleanest match to "Rate My Healthcare" + scoring concept. Good for SEO ("X carescore"). |
+| doctorforum.la | 13 | ~$30/yr | Exact-match to community + reviews concept. Slightly narrow (excludes nurses by name). |
+| yourdoctorla.com | 14 | ~$12/yr | Direct, corporate. Easier verbal recall than .la TLD. |
+| finddrla.com | 11 | ~$12/yr | Search-shaped. "Find Dr LA." |
+| pickdrla.com | 11 | ~$12/yr | Action-shaped. Cheaper. |
+| lacareforum.com | 14 | ~$12/yr | Forum-focused. "LA care forum." |
+| mydoclabs.com | 12 | ~$12/yr | Strongest .com but "labs" implies clinical research, slight mismatch for community/Yelp. |
+
+## Top 3 recommendations (ranked)
+
+### 1. **whichdoctor.la** ★ — recommended
+- Most memorable + most "ask a question" energy. "Which doctor?" is exactly the user mental model.
+- .la doubles as the Los Angeles geographic anchor — every patient in our data is LA-county.
+- Short, ownable, no premium fee.
+- Risk: mild "Doctor Who" pun could feel kitsch but is also actually a draw — search-friendly.
+
+### 2. **healthcity.la** — broadest fit
+- Covers all 14 senior-care org types we just imported (RCFE, ARF, HCO, hospice, nursing facility, etc.) — not just doctors.
+- Future-proof for adding therapists, dentists, optometrists explicitly.
+- Slightly less brand-bite than whichdoctor.la.
+
+### 3. **carescore.la** — rating-first
+- If the platform's core wedge is the 5-star aggregate-rating ("service / price / quality"), this is the cleanest brand match.
+- Pairs naturally with marketing copy: "Look up a doctor's CareScore."
+- Less ideal for community/forum framing.
+
+## Domains to consider as defensive holds
+
+If we pick one, register `.com` of the chosen brand simultaneously (~$12/yr) to prevent a copycat. For `whichdoctor.la`, also grab `whichdoctor.com` (probably premium) — verify before registering.
+
+## Next step
+
+User picks one of the top 3 (or another from the available list). I then:
+1. Register via the existing `domain-suite` MCP at Porkbun (cheapest .la registrar).
+2. Set NS to Cloudflare per the standard `/setup-domains` skill flow.
+3. Update CNCP `domains[]` with the new entry.
+4. Phase 1 begins.
diff --git a/docs/phase-1-codex-findings.md b/docs/phase-1-codex-findings.md
new file mode 100644
index 0000000..1297b2e
--- /dev/null
+++ b/docs/phase-1-codex-findings.md
@@ -0,0 +1,36 @@
+# Phase 1 — Codex debate findings (2026-05-01)
+
+## Status
+
+The Codex side of the adversarial debate failed all 6 rounds with **"Quota exceeded. Check your plan and billing details."** — the codex CLI's API quota is exhausted. Steve needs to refill before re-running.
+
+Claude's side ran fully and produced real findings on the **existing codebase** (since the debate ran `git review --uncommitted` against current diff, not the phase-1-design.md). The findings are real bugs to address before/during Phase 1.
+
+## CRITICAL — fix before Phase 1 ships
+
+1. **`agents/api-agent/server.js openPitchModal` textarea XSS** — `body` string-concats raw `d.name`/`d.suggested_domain` into `<textarea>` via `card.innerHTML`. Org name containing `</textarea><script>` breaks out and runs in admin origin. Fix: run every `d.*` field through `escapeHtml`.
+
+2. **`renderPreview` + grid card + pitch modal — javascript: URL in href** — `escHtml(o.website)` HTML-escapes but does not validate scheme. A `javascript:` URL survives and fires when staff click. Multiple sites: server.js:587, server.js:1071, server.js:1385. Fix: allowlist `^https?:` before emitting href.
+
+3. **`POST /organizations/:id/touch` unauthenticated** — Phase 1 was already going to add auth here. Confirmed.
+
+## HIGH — fix in Phase 1
+
+- `zip-heat` builder: `data-zip="' + z.zip + '"` unescaped → wrap in escapeHtml
+- `mapHref` interpolates `${o.lat},${o.lng}` directly into href; numeric columns return as strings → encodeURIComponent each
+- `load()` / `renderMap()`: concurrent fetches with no AbortController → reqId++ pattern
+- `/outreach.csv` filename: `req.query.category` flows into Content-Disposition → restrict to `/^[a-z_]{1,32}$/`
+- `data_market.js:651-664` CSV download: string-matches `where[]` array via `startsWith('LOWER(o.city)')` to swap in EXISTS; future predicate edit silently disables filter → emit EXISTS form on first build
+
+## MEDIUM — Phase 1 polish
+
+- `appendChild(card.parentElement === back ? back : back)` — dead ternary; both branches identical
+- Geolocation prompt fires on every public `/preview/:id` view with no explanatory copy → gate behind "Show distance" button
+- Dashboard `<select>` controls have only `title=`, no `<label for=>` → add visually-hidden labels
+- `/map.json LIMIT 80000` is a DoS amplifier → cap at 20k or stream NDJSON
+- `addPins()` is dead code — `renderMap` only calls `addPinsFromMapJson` → delete
+- `themeWord` falls back to "beautifully themed" when category empty → reads like LLM tell; per-org-type fallback instead
+
+## Action
+
+I'll patch the 3 CRITICAL findings now (~15 min) before scheduling the actual Phase 1 build. The HIGH/MEDIUM items get pulled into the Phase 1 task list for Ralph.
diff --git a/docs/phase-1-design.md b/docs/phase-1-design.md
new file mode 100644
index 0000000..7547b06
--- /dev/null
+++ b/docs/phase-1-design.md
@@ -0,0 +1,235 @@
+# Phase 1 — Auth, users, schema (DESIGN, for Codex debate)
+
+## Goal
+
+Layer Google-OAuth-backed user accounts with 4 roles (guest, patient, doctor, admin) on top of the existing pd-api Express stack, plus the doctor-claim flow and Stripe subscription wiring. All routes that mutate or expose private data must be gated by role + tier middleware. Existing loopback admin dashboard continues to work for Steve via a synthetic admin session at boot, but every other surface becomes auth-aware.
+
+## Files to create
+
+| Path | Purpose |
+|---|---|
+| `db/migrations/003_users_auth.sql` | New tables (users, doctor_claims, subscriptions, sessions) + add `claim_status` columns to professionals/organizations |
+| `agents/api-agent/auth.js` | Passport config (Google OAuth strategy + serialize/deserialize) + `requireRole()` / `requireTier()` middleware factory |
+| `agents/api-agent/routes/auth.js` | `/auth/google`, `/auth/google/callback`, `/auth/logout`, `/whoami` |
+| `agents/api-agent/routes/claims.js` | `POST /doctor-claims`, `GET /doctor-claims/:token` (email-verify), admin approve/reject |
+| `agents/api-agent/routes/subscriptions.js` | `POST /subscriptions/checkout` (Stripe Checkout for doctor_pro/patient_plus plans), webhook handler |
+| `db/migrations/003b_session_table.sql` | `connect-pg-simple` session table |
+
+## Files to modify
+
+| Path | Change |
+|---|---|
+| `agents/api-agent/server.js` | Mount `auth.js`, `claims.js`, `subscriptions.js`. Add `requireRole('admin')` middleware on every existing `/admin/*` route. Add session middleware ABOVE express.json. Inject `req.user` into existing endpoints. |
+| `agents/api-agent/data_market.js` | Webhook handler currently only handles `checkout.session.completed`; extend to also handle `customer.subscription.{created,updated,deleted}` so user.tier reflects Stripe state. |
+| `package.json` | Add `passport`, `passport-google-oauth20`, `express-session`, `connect-pg-simple`, `csurf` |
+| `.env.example` | Document `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET`, `SESSION_SECRET`, `STRIPE_SUBSCRIPTION_PRICE_DOCTOR_PRO`, `STRIPE_SUBSCRIPTION_PRICE_PATIENT_PLUS` |
+
+## Schema (003_users_auth.sql)
+
+```sql
+-- ─── users ─────────────────────────────────────────────────────────────────
+CREATE TABLE users (
+ id bigserial PRIMARY KEY,
+ email citext NOT NULL UNIQUE,
+ email_verified_at timestamptz,
+ google_sub text UNIQUE, -- Google OIDC subject; NULL until first OAuth
+ display_name text,
+ avatar_url text,
+ role text NOT NULL DEFAULT 'patient', -- guest|patient|doctor|admin
+ tier text NOT NULL DEFAULT 'free', -- free|paid
+ comments_disabled boolean NOT NULL DEFAULT false, -- patient/doctor can hide reviews on own profile
+ claimed_professional_id bigint REFERENCES professionals(id) ON DELETE SET NULL,
+ claimed_organization_id bigint REFERENCES organizations(id) ON DELETE SET NULL,
+ stripe_customer_id text UNIQUE,
+ last_login_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX idx_users_role_tier ON users (role, tier);
+CREATE INDEX idx_users_claimed_pro ON users (claimed_professional_id) WHERE claimed_professional_id IS NOT NULL;
+
+-- ─── doctor_claims ─────────────────────────────────────────────────────────
+-- A user wants to claim a professional or organization. Auto-approval if email
+-- domain matches a known practice domain AND NPI lookup succeeds. Otherwise
+-- queued for admin review.
+CREATE TABLE doctor_claims (
+ id bigserial PRIMARY KEY,
+ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ target_professional_id bigint REFERENCES professionals(id) ON DELETE CASCADE,
+ target_organization_id bigint REFERENCES organizations(id) ON DELETE CASCADE,
+ npi_provided varchar(10),
+ license_provided text,
+ email_verification_token text NOT NULL, -- emailed to practice domain
+ email_verified_at timestamptz,
+ status text NOT NULL DEFAULT 'pending', -- pending|approved|rejected|expired
+ status_reason text,
+ decided_at timestamptz,
+ decided_by_user_id bigint REFERENCES users(id),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT either_target CHECK (
+ (target_professional_id IS NOT NULL AND target_organization_id IS NULL) OR
+ (target_professional_id IS NULL AND target_organization_id IS NOT NULL)
+ )
+);
+CREATE INDEX idx_claims_status ON doctor_claims (status);
+CREATE UNIQUE INDEX idx_claims_one_pending_per_target_pro
+ ON doctor_claims (target_professional_id) WHERE status = 'pending' AND target_professional_id IS NOT NULL;
+CREATE UNIQUE INDEX idx_claims_one_pending_per_target_org
+ ON doctor_claims (target_organization_id) WHERE status = 'pending' AND target_organization_id IS NOT NULL;
+
+-- ─── subscriptions ─────────────────────────────────────────────────────────
+CREATE TABLE subscriptions (
+ id bigserial PRIMARY KEY,
+ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ stripe_subscription_id text NOT NULL UNIQUE,
+ stripe_price_id text NOT NULL,
+ plan text NOT NULL, -- doctor_pro_monthly | patient_plus_monthly
+ status text NOT NULL, -- active | past_due | canceled | unpaid | trialing
+ current_period_end timestamptz,
+ cancel_at_period_end boolean NOT NULL DEFAULT false,
+ started_at timestamptz NOT NULL DEFAULT now(),
+ ended_at timestamptz,
+ raw_json jsonb
+);
+CREATE INDEX idx_subs_user ON subscriptions (user_id);
+CREATE INDEX idx_subs_status ON subscriptions (status);
+
+-- ─── claim_status on existing tables ───────────────────────────────────────
+ALTER TABLE professionals ADD COLUMN claim_status text NOT NULL DEFAULT 'unclaimed' CHECK (claim_status IN ('unclaimed','claimed','verified'));
+ALTER TABLE organizations ADD COLUMN claim_status text NOT NULL DEFAULT 'unclaimed' CHECK (claim_status IN ('unclaimed','claimed','verified'));
+CREATE INDEX idx_pros_claim ON professionals (claim_status);
+CREATE INDEX idx_orgs_claim ON organizations (claim_status);
+
+-- updated_at triggers
+CREATE OR REPLACE FUNCTION trigger_set_timestamp() RETURNS trigger AS $$
+BEGIN NEW.updated_at = now(); RETURN NEW; END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER users_set_updated_at BEFORE UPDATE ON users
+ FOR EACH ROW EXECUTE FUNCTION trigger_set_timestamp();
+```
+
+## auth.js — passport + middleware
+
+```js
+const passport = require('passport');
+const GoogleStrategy = require('passport-google-oauth20').Strategy;
+const { query } = require('../shared/db');
+
+passport.use(new GoogleStrategy({
+ clientID: process.env.GOOGLE_OAUTH_CLIENT_ID,
+ clientSecret: process.env.GOOGLE_OAUTH_CLIENT_SECRET,
+ callbackURL: process.env.GOOGLE_OAUTH_CALLBACK || '/auth/google/callback',
+}, async (accessToken, refreshToken, profile, done) => {
+ try {
+ const email = (profile.emails?.[0]?.value || '').toLowerCase();
+ if (!email) return done(new Error('no email from Google'));
+ const r = await query(`
+ INSERT INTO users (email, google_sub, display_name, avatar_url, email_verified_at, last_login_at)
+ VALUES ($1, $2, $3, $4, now(), now())
+ ON CONFLICT (email) DO UPDATE SET
+ google_sub = COALESCE(users.google_sub, EXCLUDED.google_sub),
+ display_name = COALESCE(users.display_name, EXCLUDED.display_name),
+ avatar_url = COALESCE(users.avatar_url, EXCLUDED.avatar_url),
+ last_login_at = now()
+ RETURNING *
+ `, [email, profile.id, profile.displayName, profile.photos?.[0]?.value]);
+ done(null, r.rows[0]);
+ } catch (e) { done(e); }
+}));
+
+passport.serializeUser((u, cb) => cb(null, u.id));
+passport.deserializeUser(async (id, cb) => {
+ const r = await query('SELECT * FROM users WHERE id = $1', [id]);
+ cb(null, r.rows[0] || null);
+});
+
+function requireRole(...roles) {
+ return (req, res, next) => {
+ if (!req.user) return res.status(401).json({ error: 'auth required' });
+ if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'forbidden' });
+ next();
+ };
+}
+function requireTier(tier) {
+ return (req, res, next) => {
+ if (!req.user) return res.status(401).json({ error: 'auth required' });
+ if (req.user.tier !== tier && req.user.role !== 'admin') return res.status(402).json({ error: 'paid tier required' });
+ next();
+ };
+}
+
+module.exports = { passport, requireRole, requireTier };
+```
+
+## Doctor-claim flow
+
+1. Authenticated user (any role) hits `POST /doctor-claims` with `{ target_professional_id, npi, license_number }` OR `{ target_organization_id, email_at_practice }`.
+2. Server checks: NPI matches professionals row + license_number matches OR target organization exists + email domain matches the org's website domain.
+3. Server creates `doctor_claims` row with status='pending', generates `email_verification_token`.
+4. Sends verification email to user's auth email AND to a known contact email at the target (if any) via George gmail (`localhost:9850/api/send`). Email contains link `/doctor-claims/<token>`.
+5. User clicks the link → server marks `email_verified_at`. If auto-approval gates pass (NPI match + email at practice domain), status flips to 'approved'; user.role bumps to 'doctor'; user.claimed_professional_id is set; professionals.claim_status flips to 'verified'.
+6. Otherwise queued for admin manual review at `/admin/claims`.
+
+## Stripe subscription wiring (extending data_market.js)
+
+- Two products: `doctor_pro_monthly` (TBD price), `patient_plus_monthly` (TBD price). Created in Stripe via `stripe products create` once, IDs stored in `.env` as `STRIPE_PRICE_DOCTOR_PRO`, `STRIPE_PRICE_PATIENT_PLUS`.
+- `POST /subscriptions/checkout` — accepts `{ plan }`, creates Stripe Checkout session, returns URL. Customer ID is reused if user.stripe_customer_id is set.
+- Webhook handler now branches on event type:
+ - `checkout.session.completed` (existing data-market path stays) — for one-time CSV downloads.
+ - `customer.subscription.created/updated` — upsert into `subscriptions` table; if active, set `users.tier = 'paid'`.
+ - `customer.subscription.deleted` — set `users.tier = 'free'`.
+- Webhook is signed; existing signature-verify logic stays.
+
+## Synthetic admin session
+
+Steve drives the loopback dashboard at `http://127.0.0.1:9874` without going through Google OAuth. Solution: a small middleware that, when `req.connection.remoteAddress === '127.0.0.1'` AND `process.env.ADMIN_LOOPBACK_TRUST === '1'`, injects a synthetic `req.user = { role: 'admin', email: 'steve@designerwallcoverings.com', id: 0 }`. This preserves Steve's existing workflow while remote callers (via pd-preview tunnel or the Next.js public site) have no such bypass.
+
+## Verification (Phase 1 done = these all pass)
+
+```bash
+# 1. /auth/google redirects to Google
+curl -sI http://127.0.0.1:9874/auth/google | grep -i location
+
+# 2. /whoami returns null when unauthenticated
+curl -s http://127.0.0.1:9874/whoami | jq .
+
+# 3. /admin/* now requires admin role from non-loopback (test via Cloudflare tunnel later)
+
+# 4. Doctor-claim flow end-to-end
+psql -d doctor_professional_directory -c "SELECT COUNT(*) FROM doctor_claims;"
+
+# 5. Stripe subscription mock-completed → user.tier flips
+node scripts/mock-stripe-subscription-event.js
+
+# 6. requireRole + requireTier behave correctly (unit tests in agents/api-agent/__tests__/auth.test.js)
+```
+
+## Risks / open questions for Codex debate
+
+1. **Session storage** — express-session with PG store (connect-pg-simple) is the standard. Alternative: signed JWTs with no server-side session. JWTs are stateless but make role changes painful (e.g. role bump after claim approval requires user re-login OR aggressive token TTL). Default: PG sessions.
+2. **CSRF** — express-session + traditional forms need CSRF tokens. The Next.js public site (Phase 2) uses fetch with same-origin credentials, so SameSite=Lax cookies cover most cases. Decide whether to add `csurf` now or in Phase 2.
+3. **Multi-claim per professional** — should two users be able to claim the same NPI (e.g. doctor + their office manager)? Default: no, one approved claim per professional. Office-manager pattern handled by adding a `delegate_user_ids[]` column on professionals for paid doctor accounts.
+4. **Soft-delete vs hard-delete on user account** — privacy requirements (CCPA) suggest hard-delete-on-request. Plan: `users.deleted_at` column + nightly purge of older-than-30d soft-deletes.
+5. **Email domain matching for claims** — gmail/yahoo/outlook are common practice emails for solos. Auto-approve only when email domain matches the org's website domain; queue everything else.
+6. **Testing strategy** — Phase 1 ships unit tests for middleware + an integration test that runs the OAuth callback against a Google sandbox tenant. CI is currently absent; Ralph subtasks include "set up GitHub Actions" if Steve wants. Default: skip CI for now, ship local jest tests.
+
+## Subtask breakdown for Ralph (after Codex debate converges)
+
+```
+1. Migration 003_users_auth.sql + apply to local PG [no-deps]
+2. Migration 003b_session_table.sql [no-deps]
+3. Install passport + session deps + commit [deps: 1,2]
+4. Create agents/api-agent/auth.js [deps: 3]
+5. Create agents/api-agent/routes/auth.js [deps: 4]
+6. Mount session middleware + auth routes in server.js [deps: 5]
+7. Loopback synthetic-admin middleware [deps: 6]
+8. Add requireRole on existing /admin/* routes [deps: 7]
+9. Create routes/claims.js (submit/verify/approve) [deps: 6]
+10. Wire George gmail for claim verification emails [deps: 9]
+11. Stripe subscription endpoint + Checkout session [deps: 6]
+12. Extend data_market.js webhook for sub events [deps: 11]
+13. Unit tests: auth middleware, claim logic [deps: 9,11]
+14. Integration test: OAuth roundtrip [deps: 5]
+15. /whoami endpoint + smoke test [deps: 6]
+```
diff --git a/ecosystem.config.js b/ecosystem.config.js
index a72f8aa..2213b90 100644
--- a/ecosystem.config.js
+++ b/ecosystem.config.js
@@ -1,52 +1,22 @@
-/**
- * Master PM2 config — 5 agents.
- * pm2 start ecosystem.config.js
- */
module.exports = {
- apps: [
- {
- name: 'pd-ingest',
- script: 'agents/ingest-agent/server.js',
- cwd: __dirname,
- env: { NODE_ENV: 'production', PORT: 9870 },
- max_memory_restart: '1G',
- autorestart: true,
- instances: 1,
- exec_mode: 'fork',
- out_file: 'logs/pd-ingest.out.log',
- error_file: 'logs/pd-ingest.err.log',
- },
- {
- name: 'pd-crawler',
- script: 'agents/crawler-agent/server.js',
- cwd: __dirname,
- env: { NODE_ENV: 'production', PORT: 9871 },
- max_memory_restart: '1G',
- autorestart: true,
- },
- {
- name: 'pd-enrich',
- script: 'agents/enrich-agent/server.js',
- cwd: __dirname,
- env: { NODE_ENV: 'production', PORT: 9872 },
- max_memory_restart: '512M',
- autorestart: true,
- },
- {
- name: 'pd-dedupe',
- script: 'agents/dedupe-agent/server.js',
- cwd: __dirname,
- env: { NODE_ENV: 'production', PORT: 9873 },
- max_memory_restart: '512M',
- autorestart: true,
- },
- {
- name: 'pd-api',
- script: 'agents/api-agent/server.js',
- cwd: __dirname,
- env: { NODE_ENV: 'production', PORT: 9874 },
- max_memory_restart: '512M',
- autorestart: true,
- },
- ],
+ apps : [{
+ script: 'index.js',
+ watch: '.'
+ }, {
+ script: './service-worker/',
+ watch: ['./service-worker']
+ }],
+
+ deploy : {
+ production : {
+ user : 'SSH_USERNAME',
+ host : 'SSH_HOSTMACHINE',
+ ref : 'origin/master',
+ repo : 'GIT_REPOSITORY',
+ path : 'DESTINATION_PATH',
+ 'pre-deploy-local': '',
+ 'post-deploy' : 'npm install && pm2 reload ecosystem.config.js --env production',
+ 'pre-setup': ''
+ }
+ }
};
diff --git a/package-lock.json b/package-lock.json
index 5daed7b..51cb2b5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,19 +18,53 @@
],
"dependencies": {
"cheerio": "^1.2.0",
+ "connect-pg-simple": "^10.0.0",
+ "cookie-parser": "^1.4.7",
"csv-parse": "^6.2.1",
"csv-stringify": "^6.7.0",
"dotenv": "^17.4.2",
"express": "^5.2.1",
+ "express-session": "^1.19.0",
+ "helmet": "^8.1.0",
+ "passport": "^0.7.0",
+ "passport-google-oauth20": "^2.0.0",
"pg": "^8.20.0",
"robots-parser": "^3.0.1",
+ "stripe": "^22.1.0",
"undici": "^7.25.0"
+ },
+ "devDependencies": {
+ "playwright": "^1.59.1"
+ }
+ },
+ "../directory-core": {
+ "version": "0.1.0",
+ "dependencies": {
+ "bcryptjs": "^2.4.3",
+ "cookie": "^0.6.0",
+ "dotenv": "^16.4.0",
+ "robots-parser": "^3.0.1",
+ "undici": "^6.19.0"
+ },
+ "devDependencies": {
+ "@types/bcryptjs": "^2.4.6",
+ "@types/cookie": "^0.6.0",
+ "@types/express": "^4.0.0",
+ "@types/node": "^22.0.0",
+ "@types/pg": "^8.0.0",
+ "tsx": "^4.0.0",
+ "typescript": "^5.0.0"
+ },
+ "peerDependencies": {
+ "express": "^4.0.0",
+ "pg": "^8.0.0"
}
},
"agents/shared": {
"name": "@pd/shared",
"version": "0.1.0",
"dependencies": {
+ "directory-core": "file:../../../directory-core",
"dotenv": "^16.4.7",
"pg": "^8.13.1",
"robots-parser": "^3.0.1",
@@ -66,6 +100,15 @@
"node": ">= 0.6"
}
},
+ "node_modules/base64url": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz",
+ "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -192,6 +235,18 @@
"url": "https://github.com/sponsors/fb55"
}
},
+ "node_modules/connect-pg-simple": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz",
+ "integrity": "sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==",
+ "license": "MIT",
+ "dependencies": {
+ "pg": "^8.12.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=22.0.0"
+ }
+ },
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@@ -223,6 +278,25 @@
"node": ">= 0.6"
}
},
+ "node_modules/cookie-parser": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/cookie-parser/node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "license": "MIT"
+ },
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
@@ -298,6 +372,10 @@
"node": ">= 0.8"
}
},
+ "node_modules/directory-core": {
+ "resolved": "../directory-core",
+ "link": true
+ },
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -507,6 +585,50 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/express-session": {
+ "version": "1.19.0",
+ "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
+ "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "~0.7.2",
+ "cookie-signature": "~1.0.7",
+ "debug": "~2.6.9",
+ "depd": "~2.0.0",
+ "on-headers": "~1.1.0",
+ "parseurl": "~1.3.3",
+ "safe-buffer": "~5.2.1",
+ "uid-safe": "~2.1.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-session/node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/express-session/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/express-session/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -546,6 +668,21 @@
"node": ">= 0.8"
}
},
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -628,6 +765,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/helmet": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
+ "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
@@ -794,6 +940,12 @@
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
+ "node_modules/oauth": {
+ "version": "0.10.2",
+ "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz",
+ "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==",
+ "license": "MIT"
+ },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -818,6 +970,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/on-headers": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
+ "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -885,6 +1046,64 @@
"node": ">= 0.8"
}
},
+ "node_modules/passport": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
+ "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "passport-strategy": "1.x.x",
+ "pause": "0.0.1",
+ "utils-merge": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/jaredhanson"
+ }
+ },
+ "node_modules/passport-google-oauth20": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz",
+ "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==",
+ "license": "MIT",
+ "dependencies": {
+ "passport-oauth2": "1.x.x"
+ },
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/passport-oauth2": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz",
+ "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==",
+ "license": "MIT",
+ "dependencies": {
+ "base64url": "3.x.x",
+ "oauth": "0.10.x",
+ "passport-strategy": "1.x.x",
+ "uid2": "0.0.x",
+ "utils-merge": "1.x.x"
+ },
+ "engines": {
+ "node": ">= 0.4.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/jaredhanson"
+ }
+ },
+ "node_modules/passport-strategy": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
+ "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
@@ -895,6 +1114,11 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/pause": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
+ "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
+ },
"node_modules/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
@@ -984,6 +1208,38 @@
"split2": "^4.1.0"
}
},
+ "node_modules/playwright": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
+ "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+ "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -1051,6 +1307,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/random-bytes": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
+ "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -1116,6 +1381,26 @@
"node": ">= 18"
}
},
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -1263,6 +1548,23 @@
"node": ">= 0.8"
}
},
+ "node_modules/stripe": {
+ "version": "22.1.0",
+ "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.1.0.tgz",
+ "integrity": "sha512-w/xHyJGxXWnLPbNHG13sz/fae0MrFGC80Oz7YbICQymbfpqfEcsoG+6yG+9BWb81PWc4rrkeSO4wmTcmefmbLw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1286,6 +1588,24 @@
"node": ">= 0.6"
}
},
+ "node_modules/uid-safe": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
+ "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
+ "license": "MIT",
+ "dependencies": {
+ "random-bytes": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/uid2": {
+ "version": "0.0.4",
+ "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz",
+ "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==",
+ "license": "MIT"
+ },
"node_modules/undici": {
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
@@ -1304,6 +1624,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
diff --git a/package.json b/package.json
index 8e2e6ac..d02a0a0 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,8 @@
"seed:specialties": "node scripts/seed_specialties.js",
"ingest:npi": "node agents/ingest-agent/importers/npi.js",
"ingest:npi:smoke": "SMOKE=1 node agents/ingest-agent/importers/npi.js",
+ "crawl:frontpage": "node agents/crawler-agent/crawlers/front-page.js",
+ "enrich:adsignals": "node src/enrich/ad_signals.js",
"stats": "node scripts/stats.js",
"api": "node agents/api-agent/server.js",
"pm2:start": "pm2 start ecosystem.config.js",
@@ -27,12 +29,22 @@
},
"dependencies": {
"cheerio": "^1.2.0",
+ "connect-pg-simple": "^10.0.0",
+ "cookie-parser": "^1.4.7",
"csv-parse": "^6.2.1",
"csv-stringify": "^6.7.0",
"dotenv": "^17.4.2",
"express": "^5.2.1",
+ "express-session": "^1.19.0",
+ "helmet": "^8.1.0",
+ "passport": "^0.7.0",
+ "passport-google-oauth20": "^2.0.0",
"pg": "^8.20.0",
"robots-parser": "^3.0.1",
+ "stripe": "^22.1.0",
"undici": "^7.25.0"
+ },
+ "devDependencies": {
+ "playwright": "^1.59.1"
}
}
diff --git a/scripts/aggregate-ratings.js b/scripts/aggregate-ratings.js
new file mode 100644
index 0000000..287ff36
--- /dev/null
+++ b/scripts/aggregate-ratings.js
@@ -0,0 +1,71 @@
+#!/usr/bin/env node
+/**
+ * Recompute aggregated_ratings from reviews. Run nightly via launchd.
+ *
+ * - Excludes suppressed_by_target=true and hidden_by_admin=true.
+ * - Includes reviews where go_live_at <= now() OR go_live_at IS NULL (seeded).
+ * - Counts user reviews + seeded reviews separately.
+ */
+const { pool, query } = require('../agents/shared/db');
+
+async function main() {
+ const t0 = Date.now();
+ console.log('[agg] recomputing aggregated_ratings…');
+
+ // Wipe + rebuild — small enough table that a full recompute is fine.
+ await query(`TRUNCATE aggregated_ratings RESTART IDENTITY`);
+
+ // Per-professional aggregation.
+ await query(`
+ INSERT INTO aggregated_ratings (target_professional_id,
+ service_avg, price_avg, quality_avg, overall_avg,
+ n_reviews, n_user_reviews, n_seeded_reviews)
+ SELECT target_professional_id,
+ ROUND(AVG(service_score)::numeric, 2),
+ ROUND(AVG(price_score)::numeric, 2),
+ ROUND(AVG(quality_score)::numeric, 2),
+ ROUND(AVG(overall_score)::numeric, 2),
+ COUNT(*),
+ COUNT(*) FILTER (WHERE source = 'user'),
+ COUNT(*) FILTER (WHERE source <> 'user')
+ FROM reviews
+ WHERE target_professional_id IS NOT NULL
+ AND suppressed_by_target = false
+ AND hidden_by_admin = false
+ AND (go_live_at IS NULL OR go_live_at <= now())
+ GROUP BY target_professional_id
+ `);
+
+ // Per-organization aggregation.
+ await query(`
+ INSERT INTO aggregated_ratings (target_organization_id,
+ service_avg, price_avg, quality_avg, overall_avg,
+ n_reviews, n_user_reviews, n_seeded_reviews)
+ SELECT target_organization_id,
+ ROUND(AVG(service_score)::numeric, 2),
+ ROUND(AVG(price_score)::numeric, 2),
+ ROUND(AVG(quality_score)::numeric, 2),
+ ROUND(AVG(overall_score)::numeric, 2),
+ COUNT(*),
+ COUNT(*) FILTER (WHERE source = 'user'),
+ COUNT(*) FILTER (WHERE source <> 'user')
+ FROM reviews
+ WHERE target_organization_id IS NOT NULL
+ AND suppressed_by_target = false
+ AND hidden_by_admin = false
+ AND (go_live_at IS NULL OR go_live_at <= now())
+ GROUP BY target_organization_id
+ `);
+
+ const stats = (await query(`
+ SELECT COUNT(*) AS rows,
+ SUM(n_reviews)::int AS total_reviews,
+ SUM(n_user_reviews)::int AS user_reviews,
+ SUM(n_seeded_reviews)::int AS seeded_reviews
+ FROM aggregated_ratings`)).rows[0];
+
+ console.log(`[agg] done in ${(Date.now() - t0) / 1000}s. rows=${stats.rows} total_reviews=${stats.total_reviews} user=${stats.user_reviews} seeded=${stats.seeded_reviews}`);
+ await pool.end();
+}
+
+main().catch(async e => { console.error(e); try { await pool.end(); } catch (_) {}; process.exit(1); });
diff --git a/scripts/apply-templates.js b/scripts/apply-templates.js
index 118e14f..744db89 100644
--- a/scripts/apply-templates.js
+++ b/scripts/apply-templates.js
@@ -36,6 +36,13 @@ const BUSINESSES = [
{ id: '74833', name: 'Amanda Taylor Esthetics', city: 'Westlake Village', st: 'CA', zip: '91361', addr: '4854 E Thousand Oaks Bl',phone: '(805) 449-7100', specialty: 'aesthetics' },
{ id: '74944', name: 'Ashley G at Hairitage', city: 'Los Angeles', st: 'CA', zip: '90046', addr: '7660 Beverly Blvd', phone: '(323) 651-1111', specialty: 'hair' },
{ id: '75645', name: 'Ian Hamel, DDS Inc.', city: 'Burbank', st: 'CA', zip: '91505', addr: '2625 W Alameda Ave', phone: '(818) 841-3601', specialty: 'dds' },
+ { id: '73303', name: 'Wynn Nail Spa', city: 'Los Angeles', st: 'CA', zip: '90004', addr: '4321 Beverly Blvd', phone: '(323) 660-1133', specialty: 'nail' },
+ { id: '74987', name: "Angel's Touch", city: 'Los Angeles', st: 'CA', zip: '90042', addr: '6014 N Figueroa St', phone: '(323) 254-7700', specialty: 'hair' },
+ { id: '75922', name: 'Fashion Optical', city: 'San Diego', st: 'CA', zip: '92103', addr: '3785 5th Ave', phone: '(619) 296-2222', specialty: 'optometry' },
+ { id: '75972', name: 'Ocean & Land Corp', city: 'Los Angeles', st: 'CA', zip: '90015', addr: '1330 S Figueroa St', phone: '(213) 765-4400', specialty: 'aesthetics' },
+ { id: '76473', name: 'Fitstop', city: 'Woodland Hills', st: 'CA', zip: '91367', addr: '21900 Burbank Blvd', phone: '(818) 992-6500', specialty: 'fitness' },
+ { id: '76523', name: 'REAXN Performance Training', city: 'Burbank', st: 'CA', zip: '91504', addr: '2735 N Hollywood Way', phone: '(818) 845-2255', specialty: 'fitness' },
+ { id: '77031', name: 'Dog Life Whittier', city: 'Whittier', st: 'CA', zip: '90601', addr: '6780 Greenleaf Ave', phone: '(562) 698-8855', specialty: 'petcare' },
];
const HOOKS = {
@@ -115,6 +122,63 @@ const HOOKS = {
keywords: ['NO PACKAGES', 'SINGLE-SESSION ONLY', 'PUBLISHED PRICING', 'MEDICAL-GRADE'],
typeLabel: 'AESTHETICS',
},
+ optometry: {
+ nounSing: 'eye exam', kicker: 'A neighborhood optometry practice',
+ heroLine: 'Frames you actually want to wear.',
+ storyTitle: 'Why we curate frames the way we do',
+ storyP1: 'Most optical shops carry whatever the buying group puts in their catalog. Two big distributors control 80% of the frames in the United States, and the rest is private-label markup at 8x cost. The result is a wall of frames that all look slightly the same.',
+ storyP2: 'We buy from designers we visit ourselves once a year at trade shows in Milan, Paris, and New York. Lindberg, Cutler & Gross, Garrett Leight, vintage Persol. The choice costs us about 20% off our gross margin per frame. It buys us a frame wall that is actually worth choosing from.',
+ services: [
+ { name: 'Routine eye exam', sub: '30-min vision + pressure check', price: '$95', who: '30 min · Doctor' },
+ { name: 'Comprehensive exam', sub: 'OCT imaging + 75-min exam', price: '$165', who: '75 min · Doctor' },
+ { name: 'Pediatric exam', sub: 'Children 6 and up, EPSDT-covered', price: '$95', who: '30 min · Doctor' },
+ { name: 'Contact-lens fitting', sub: 'Trial pair + 1-week follow-up', price: '$140', who: '60 min · Doctor' },
+ { name: 'Frames & lenses', sub: 'Curated houses, in-house lab', price: 'From $290', who: '60-min fitting' },
+ ],
+ quote: '"Every other shop tried to sell me Ray-Bans I would have hated in two months. They put a $340 Lindberg titanium on my face and I haven\'t wanted to switch in four years."',
+ quoteAuthor: '— Daniel T., 4-year patient',
+ bookCadence: 'Same-week routine slots. Comprehensive exams 3-5 days out.',
+ keywords: ['CURATED FRAMES', 'IN-HOUSE LAB', 'INSURANCE ACCEPTED', 'NEIGHBORHOOD'],
+ typeLabel: 'OPTOMETRY',
+ },
+ fitness: {
+ nounSing: 'session', kicker: 'A small-group performance training studio',
+ heroLine: 'Stronger than you were six weeks ago.',
+ storyTitle: 'Why we cap classes at six',
+ storyP1: 'The big-box gyms run group classes at 30 people. The trainer cannot watch your form, cannot adjust your loading, cannot call out the eight people doing the squat with their knees collapsing inward. That is how three-quarters of the people who join a gym in January wreck a knee by March.',
+ storyP2: 'We cap our small-group classes at six. Every session has a coach who knows your name, your movement history, and your loading from last week. Programming is written by us, not pulled from a franchise app. We charge what that costs ($35 a class, $280/month unlimited), and we keep the room small.',
+ services: [
+ { name: 'Drop-in class', sub: 'One small-group session, any time', price: '$35', who: '60 min · max 6' },
+ { name: 'Unlimited monthly', sub: 'All small-group classes', price: '$280/mo', who: 'No commitment' },
+ { name: '1:1 personal training', sub: 'Custom programming, written for you', price: '$120', who: '60 min · Coach' },
+ { name: 'New-member assessment', sub: 'Movement screen + intro session', price: 'Free', who: '60 min · once' },
+ { name: 'Sports-performance block', sub: '8-week season prep, 3x/week', price: '$895', who: '8 weeks' },
+ ],
+ quote: '"I joined three big-box gyms in two years and quit each one. Six weeks here and I haven\'t missed a session."',
+ quoteAuthor: '— Marisa K., member since 2024',
+ bookCadence: 'Walk-ins welcome for assessment. Class slots open 7 days ahead.',
+ keywords: ['CAPPED AT SIX', 'WRITTEN PROGRAMMING', 'COACH-LED', 'NO FRANCHISE'],
+ typeLabel: 'PERFORMANCE TRAINING',
+ },
+ petcare: {
+ nounSing: 'visit', kicker: 'A neighborhood dog daycare',
+ heroLine: 'Where your dog wants to be while you are at work.',
+ storyTitle: 'Why we limit headcount to twenty',
+ storyP1: 'The big chain doggy daycares run forty to sixty dogs a day per facility. The math works: high turnover, $40 a day, automated check-in. The dogs are stressed by the noise, the staff cannot tell who is bullying whom, and the floor never gets disinfected to the standard a healthy adult immune system needs.',
+ storyP2: 'We limit headcount to twenty dogs at a time, by appointment, with a temperament assessment before any new dog joins the pack. We charge what that costs ($55 a day, $250 a week), and our staff knows every dog by name and what their pulling triggers are.',
+ services: [
+ { name: 'Daycare day', sub: 'Drop-off 7-9am, pickup by 6pm', price: '$55', who: 'Full day · max 20' },
+ { name: 'Weekly bundle', sub: '5 days, prepaid', price: '$250', who: '$50/day' },
+ { name: 'Boarding overnight', sub: 'Dogs assessed pre-stay', price: '$85', who: 'Per night' },
+ { name: 'Group walk', sub: '60-min neighborhood walk, max 4 dogs', price: '$30', who: '60 min · 1:4' },
+ { name: 'Temperament assessment', sub: 'Required before first daycare day', price: 'Free', who: '90 min · once' },
+ ],
+ quote: '"My anxious rescue would not look at the staff at the chain. Three weeks here and he runs to the door when we pull up."',
+ quoteAuthor: '— Anika P., owner of Marvel',
+ bookCadence: 'Daycare books 2-3 days ahead. Boarding 2 weeks for weekends.',
+ keywords: ['CAPPED AT TWENTY', 'PRE-ASSESSED PACK', 'BY APPOINTMENT', 'NO CHAIN'],
+ typeLabel: 'DOG DAYCARE',
+ },
dds: {
nounSing: 'cleaning', kicker: 'A neighborhood dental practice',
heroLine: 'A dentist who looks at you, not at your phone screen.',
diff --git a/scripts/audit-mockups.js b/scripts/audit-mockups.js
new file mode 100644
index 0000000..fa1d3bc
--- /dev/null
+++ b/scripts/audit-mockups.js
@@ -0,0 +1,164 @@
+#!/usr/bin/env node
+/**
+ * audit-mockups.js — run the v5 validator over every existing mockup HTML
+ * file in data/mockups/ and report tainted vs clean outputs.
+ *
+ * Read-only: writes a single _v5_audit_report.jsonl + prints a summary.
+ * Does NOT delete or regenerate anything. Use the output to decide which
+ * orgs need a v5 re-gen pass.
+ */
+const fs = require('fs');
+const path = require('path');
+const { pool, query } = require('../agents/shared/db');
+
+const MOCKUP_DIR = path.join(__dirname, '..', 'data', 'mockups');
+const REPORT_PATH = path.join(MOCKUP_DIR, '_v5_audit_report.jsonl');
+
+// ── lifted from generate-mockups-v5.js ────────────────────────────────────
+function classifyOrgType(o) {
+ const n = String(o.name || '').toLowerCase();
+ const t = String(o.type || '').toLowerCase();
+ if (/\b(rcfe|arf|hco|assisted living|memory care|skilled nursing|nursing home|board.{1,4}care|hospice|home health)\b/.test(t + ' ' + n)) return 'eldercare';
+ if (/\b(salon|hair|barber|nail|lash|brow|wax|spa|aesthetic|beauty|massage|sauna|float|color bar)\b/.test(n)) {
+ if (/\b(med ?spa|medspa|medical spa|botox|filler|laser|coolsculpt|injection)\b/.test(n)) return 'spa_aesthetics';
+ if (/\b(salon|hair|barber|nail|lash|brow|wax|color bar)\b/.test(n)) return 'salon';
+ return 'spa_aesthetics';
+ }
+ if (/\b(gym|fitness|crossfit|pilates|yoga|barre|cycling|cycle bar|spin|strength|f45)\b/.test(n)) return 'gym_wellness';
+ if (/\b(dental|dentist|dds|orthodont|endodont|periodont|prosthodont|oral surg)\b/.test(t + ' ' + n)) return 'dentist';
+ if (/\b(chiropract|chiro\b|dc\s)/i.test(t + ' ' + n)) return 'chiropractor';
+ if (/\b(acupunct|herb|tcm|traditional chinese|oriental medicine|l\.?ac\.?\b)\b/.test(t + ' ' + n)) return 'acupuncture';
+ if (/\b(optom|optical|eye care|vision center|ophthalm)\b/.test(t + ' ' + n)) return 'optometry';
+ if (/\b(podiat|foot.{0,5}clinic|dpm)\b/.test(t + ' ' + n)) return 'podiatry';
+ if (/\b(physical therapy|pt clinic|sports med|rehab|orthoped)\b/.test(t + ' ' + n)) return 'physical_therapy';
+ if (/\b(mental health|psychiatr|psycholog|therapy|counsel|behavioral)\b/.test(t + ' ' + n)) return 'mental_health';
+ if (/\b(veterin|animal hosp|pet clinic|dvm)\b/.test(t + ' ' + n)) return 'vet';
+ if (/\b(urgent care|walk.?in clinic|express care)\b/.test(t + ' ' + n)) return 'urgent_care';
+ if (/\b(hospital|medical center)\b/.test(t + ' ' + n) && !/clinic\b/.test(n)) return 'hospital';
+ if (/\b(md|m\.d\.|physician|primary care|family medicine|internal medicine|pediatric|cardiolog|dermatolog|gastro|endocrin|neuro|onc|gyne|obgyn|ob.?gyn)\b/.test(t + ' ' + n)) {
+ if (/\b(primary care|family|internal|pediatric)\b/.test(t + ' ' + n)) return 'primary_care';
+ return 'specialty_md';
+ }
+ return 'generic';
+}
+
+const BANNED_WORDS = [
+ /\bexceptional\b/i, /\bpremium\b/i, /\belevat(e|ed|ing)\b/i, /\bworld[- ]class\b/i,
+ /\bcutting[- ]edge\b/i, /\bstate[- ]of[- ]the[- ]art\b/i, /\bleverage\b/i, /\bempower\b/i,
+ /\bstreamline\b/i, /\bunparalleled\b/i, /\bpassionate about\b/i,
+ /our team of dedicated professionals/i, /we are committed to providing/i,
+];
+
+function validateHtml(html, orgType) {
+ const issues = [];
+ for (const r of BANNED_WORDS) {
+ const m = html.match(r);
+ if (m) issues.push(`banned phrase: "${m[0]}"`);
+ }
+ if (/via\.placeholder\.com|placeholder\.com\/\d|placehold\.co|placeimg\.com|loremflickr\.com/i.test(html)) issues.push('placeholder image URL');
+ if (/<img[^>]+src=["']\s*["']/i.test(html)) issues.push('empty image src');
+ if (/\b(Team Member|Doctor|Provider|Practitioner|Member|Stylist|Coach)\s+\d\b/i.test(html)) issues.push('"Team Member N" placeholder name');
+ if (/\b(Dr\.|MD|DDS|DC|LAc|L\.Ac\.)\s+(Lastname|Smith|Doe|Person)\b/i.test(html)) issues.push('placeholder doctor name');
+ const hasFaClasses = /<i\s+class=["'](fa[srlb]?|fab|fas|far|fal)\s+fa-/i.test(html);
+ const hasFaLoaded = /font.?awesome|use\.fontawesome|cdnjs\.cloudflare\.com\/ajax\/libs\/font-awesome/i.test(html);
+ if (hasFaClasses && !hasFaLoaded) issues.push('Font Awesome classes used without FA stylesheet loaded');
+ const nonMedical = ['salon', 'spa_aesthetics', 'gym_wellness'];
+ if (nonMedical.includes(orgType)) {
+ if (/\b(insurance|copay|telehealth|medical advice|HIPAA|prescription|refill)\b/i.test(html)) issues.push(`non-medical org (${orgType}) page mentions clinical/insurance topics`);
+ }
+ if ((orgType === 'salon' || orgType === 'gym_wellness') && /not (medical|clinical) advice|in[- ]person (clinical|medical) (exam|consultation)/i.test(html)) {
+ issues.push(`${orgType} should not carry medical-advice disclaimer`);
+ }
+ if (!/<title>[^<]+<\/title>/i.test(html)) issues.push('missing <title>');
+ if (!/<meta\s+name=["']description["']/i.test(html)) issues.push('missing meta description');
+ if (!/application\/ld\+json/i.test(html)) issues.push('missing JSON-LD');
+ return { ok: issues.length === 0, issues };
+}
+
+// ── walk + report ─────────────────────────────────────────────────────────
+async function main() {
+ const files = fs.readdirSync(MOCKUP_DIR)
+ .filter(f => f.endsWith('.html') && !f.startsWith('_'))
+ .sort();
+ console.log(`[audit] scanning ${files.length} mockup files`);
+
+ // collect distinct org IDs
+ const orgIds = [...new Set(files.map(f => f.split('-')[0]).filter(s => /^\d+$/.test(s)))];
+ console.log(`[audit] looking up ${orgIds.length} distinct orgs from PG`);
+
+ const r = await query(
+ `SELECT id, name, type FROM organizations WHERE id = ANY($1::int[])`,
+ [orgIds.map(Number)]
+ );
+ const orgById = new Map(r.rows.map(o => [String(o.id), o]));
+
+ // Truncate previous report
+ fs.writeFileSync(REPORT_PATH, '');
+
+ const stats = {
+ total: 0, ok: 0, tainted: 0, missing_org: 0,
+ issue_counts: {},
+ by_orgtype: {},
+ };
+ const taintedFiles = [];
+
+ for (const f of files) {
+ stats.total++;
+ const orgId = f.split('-')[0];
+ const org = orgById.get(orgId);
+ if (!org) {
+ stats.missing_org++;
+ const rec = { ts: new Date().toISOString(), file: f, ok: false, issues: ['org not in DB'], orgType: null };
+ fs.appendFileSync(REPORT_PATH, JSON.stringify(rec) + '\n');
+ continue;
+ }
+ const orgType = classifyOrgType(org);
+ const html = fs.readFileSync(path.join(MOCKUP_DIR, f), 'utf8');
+ const v = validateHtml(html, orgType);
+ stats.by_orgtype[orgType] = stats.by_orgtype[orgType] || { ok: 0, tainted: 0 };
+ if (v.ok) {
+ stats.ok++;
+ stats.by_orgtype[orgType].ok++;
+ } else {
+ stats.tainted++;
+ stats.by_orgtype[orgType].tainted++;
+ taintedFiles.push({ file: f, orgId, orgType, issues: v.issues });
+ for (const issue of v.issues) {
+ const key = issue.replace(/"[^"]+"/g, '"…"').slice(0, 80);
+ stats.issue_counts[key] = (stats.issue_counts[key] || 0) + 1;
+ }
+ }
+ const rec = { ts: new Date().toISOString(), file: f, orgId, orgName: org.name, orgType, ok: v.ok, issues: v.issues };
+ fs.appendFileSync(REPORT_PATH, JSON.stringify(rec) + '\n');
+ }
+
+ // Print summary
+ console.log('\n=== AUDIT SUMMARY ===');
+ console.log(`total files: ${stats.total}`);
+ console.log(`clean (ok): ${stats.ok} (${(stats.ok/stats.total*100).toFixed(1)}%)`);
+ console.log(`tainted: ${stats.tainted} (${(stats.tainted/stats.total*100).toFixed(1)}%)`);
+ console.log(`missing org: ${stats.missing_org}`);
+ console.log('\n--- by org type ---');
+ for (const [t, v] of Object.entries(stats.by_orgtype).sort((a,b)=>(b[1].ok+b[1].tainted)-(a[1].ok+a[1].tainted))) {
+ const tot = v.ok + v.tainted;
+ console.log(` ${t.padEnd(18)} ${String(v.ok).padStart(4)} ok / ${String(v.tainted).padStart(4)} tainted (${tot} total, ${(v.tainted/tot*100).toFixed(0)}% bad)`);
+ }
+ console.log('\n--- top issues ---');
+ const top = Object.entries(stats.issue_counts).sort((a,b)=>b[1]-a[1]).slice(0, 15);
+ for (const [k, n] of top) console.log(` ${String(n).padStart(4)} ${k}`);
+
+ // Top tainted orgs (need re-gen)
+ const orgFails = {};
+ for (const t of taintedFiles) orgFails[t.orgId] = (orgFails[t.orgId] || 0) + 1;
+ const orgsToRegen = Object.entries(orgFails).filter(([_, n]) => n >= 2).sort((a,b)=>b[1]-a[1]);
+ console.log(`\n--- orgs with 2+ tainted variants (regen candidates): ${orgsToRegen.length} ---`);
+ for (const [oid, n] of orgsToRegen.slice(0, 20)) {
+ const o = orgById.get(oid);
+ console.log(` ${oid} ${n} bad ${o ? o.name : '?'}`);
+ }
+
+ console.log(`\nfull report: ${REPORT_PATH}`);
+ await pool.end();
+}
+
+main().catch(async e => { console.error(e); try { await pool.end(); } catch (_) {}; process.exit(1); });
diff --git a/scripts/build-pitch-html.py b/scripts/build-pitch-html.py
new file mode 100644
index 0000000..f38c419
--- /dev/null
+++ b/scripts/build-pitch-html.py
@@ -0,0 +1,179 @@
+#!/usr/bin/env python3
+"""Build pitch HTML for doctor paid-ad targets. Pure data transform, no LLM."""
+import csv, json, os, sys, subprocess
+from datetime import datetime
+from collections import Counter
+
+ROOT = os.path.expanduser('~/Projects/professional-directory')
+CSV_PATH = f'{ROOT}/exports/doctor-paid-ads-targets-2026-05-05.csv'
+OUT_PATH = f'{ROOT}/exports/doctor-sales-pitch-2026-05-05.html'
+
+REFRESH_SQL = """
+SELECT o.id, o.name, COALESCE(o.type,'') AS org_type, COALESCE(o.city,'') AS city,
+ COALESCE(o.state::text,'CA') AS state, COALESCE(o.website,'') AS website,
+ COALESCE((oe.ad_signals->>'paid_ads_count')::int,0) AS paid_pixels,
+ (oe.ad_signals->>'google_ads')::boolean AS google_ads,
+ (oe.ad_signals->>'meta_pixel')::boolean AS meta,
+ (oe.ad_signals->>'tiktok_pixel')::boolean AS tiktok,
+ (oe.ad_signals->>'bing_uet')::boolean AS bing,
+ (oe.ad_signals->>'reddit_pixel')::boolean AS reddit,
+ (oe.ad_signals->>'pinterest_tag')::boolean AS pinterest,
+ CASE WHEN (oe.ad_signals->>'paid_ads_count')::int >= 3 THEN 'TIER1_HEAVY'
+ WHEN (oe.ad_signals->>'paid_ads_count')::int = 2 THEN 'TIER2_MODERATE'
+ ELSE 'TIER3_SINGLE' END AS tier
+FROM organizations o JOIN org_enrichment oe ON oe.organization_id = o.id
+WHERE (oe.ad_signals->>'paid_ads_count')::int > 0
+ORDER BY (oe.ad_signals->>'paid_ads_count')::int DESC, o.name
+"""
+
+if '--refresh-csv' in sys.argv or not os.path.exists(CSV_PATH):
+ with open(CSV_PATH, 'w') as f:
+ subprocess.run(['psql','-d','doctor_professional_directory','-A','-F,','--csv','-c',REFRESH_SQL], stdout=f, check=True)
+
+with open(CSV_PATH) as f:
+ rows = list(csv.DictReader(f))
+
+PLATFORM_LABELS = {
+ 'google_ads': ('G','Google Ads','#4285f4'),
+ 'meta': ('M','Meta','#1877f2'),
+ 'tiktok': ('TT','TikTok','#ff0050'),
+ 'bing': ('B','Bing','#008373'),
+ 'reddit': ('R','Reddit','#ff4500'),
+ 'pinterest': ('P','Pinterest','#e60023'),
+}
+
+def chips(r):
+ out = []
+ for p,(lbl,full,col) in PLATFORM_LABELS.items():
+ if r.get(p) == 't':
+ out.append(f'<span class="chip" style="background:{col}" title="{full}">{lbl}</span>')
+ return ''.join(out)
+
+def plats(r): return [PLATFORM_LABELS[p][1] for p in PLATFORM_LABELS if r.get(p) == 't']
+
+tier_counts = Counter(r['tier'] for r in rows)
+type_counts = Counter(r['org_type'] for r in rows if r['org_type']).most_common(8)
+
+data = [{'id':r['id'],'name':r['name'],'org_type':r['org_type'],'city':r['city'],
+ 'state':r['state'],'website':r['website'],'paid_pixels':int(r['paid_pixels']),
+ 'tier':r['tier'],'platforms':plats(r),'platforms_html':chips(r)} for r in rows]
+
+generated = datetime.now().strftime('%Y-%m-%d %H:%M PT')
+total = len(rows)
+
+HTML = f"""<!doctype html>
+<html lang="en"><head><meta charset="utf-8">
+<title>Healthcare/Wellness — Paid-Ad Targets — {total}</title>
+<script>(function(){{const t=localStorage.getItem('theme')||'dark';document.documentElement.setAttribute('data-theme',t);}})();</script>
+<style>
+:root[data-theme="dark"]{{--bg:#0a0a0a;--fg:#e8e8e8;--muted:#888;--accent:#0ea5e9;--card:#161616;--border:#2a2a2a;--banner:#1a2a3a;--banner-fg:#88ccff}}
+:root[data-theme="light"]{{--bg:#fafafa;--fg:#1a1a1a;--muted:#666;--accent:#0369a1;--card:#fff;--border:#ddd;--banner:#dbeafe;--banner-fg:#1e3a5f}}
+*{{box-sizing:border-box}}body{{margin:0;font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--fg);font-size:13px}}
+.banner{{background:var(--banner);color:var(--banner-fg);padding:10px 20px;font-weight:600;border-bottom:2px solid var(--banner-fg);display:flex;justify-content:space-between;align-items:center;font-size:13px}}
+.hd{{padding:18px 20px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:flex-start;gap:20px;flex-wrap:wrap}}
+h1{{margin:0 0 4px;font-size:22px}}.sub{{color:var(--muted);font-size:12px}}
+.theme-tog{{background:none;border:1px solid var(--border);color:var(--fg);padding:6px 12px;border-radius:4px;cursor:pointer}}
+.stats{{padding:14px 20px;display:flex;gap:24px;flex-wrap:wrap;border-bottom:1px solid var(--border);background:var(--card)}}
+.stat .v{{font-size:22px;font-weight:600;color:var(--accent)}}.stat .l{{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:0.5px}}
+.controls{{padding:12px 20px;display:flex;gap:12px;align-items:center;flex-wrap:wrap;border-bottom:1px solid var(--border)}}
+.controls input[type=text]{{flex:1;min-width:200px;padding:8px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:4px}}
+.controls label{{font-size:12px;display:flex;align-items:center;gap:4px;cursor:pointer}}
+.main{{display:grid;grid-template-columns:1fr;gap:0}}.main.with-panel{{grid-template-columns:1fr 380px}}
+table{{width:100%;border-collapse:collapse;font-size:12px}}
+thead th{{text-align:left;padding:8px 10px;background:var(--card);border-bottom:1px solid var(--border);cursor:pointer;position:sticky;top:0}}
+thead th:hover{{color:var(--accent)}}tbody td{{padding:8px 10px;border-bottom:1px solid var(--border)}}tbody tr:hover{{background:var(--card)}}
+.tier-TIER1_HEAVY{{display:inline-block;padding:2px 6px;border-radius:3px;font-size:10px;font-weight:600;background:#1a3a5a;color:#88ccff}}
+.tier-TIER2_MODERATE{{display:inline-block;padding:2px 6px;border-radius:3px;font-size:10px;font-weight:600;background:#3d3a00;color:#dddd80}}
+.tier-TIER3_SINGLE{{display:inline-block;padding:2px 6px;border-radius:3px;font-size:10px;font-weight:600;background:#333;color:#aaa}}
+:root[data-theme="light"] .tier-TIER1_HEAVY{{background:#cfe7ff;color:#1a3a5a}}
+:root[data-theme="light"] .tier-TIER2_MODERATE{{background:#f5f5cc;color:#5a5a00}}
+:root[data-theme="light"] .tier-TIER3_SINGLE{{background:#eee;color:#444}}
+.chip{{display:inline-block;padding:2px 5px;border-radius:3px;font-size:10px;font-weight:600;color:#fff;margin-right:3px}}
+.btn-pitch{{background:var(--accent);color:#fff;border:none;padding:5px 10px;border-radius:3px;cursor:pointer;font-size:11px;font-weight:600}}
+a{{color:var(--accent);text-decoration:none}}a:hover{{text-decoration:underline}}
+.panel{{background:var(--card);border-left:1px solid var(--border);padding:18px;height:calc(100vh - 200px);overflow-y:auto}}
+.panel h2{{margin:0 0 8px}}.panel .pitch-script{{background:var(--bg);padding:12px;border-radius:4px;border:1px solid var(--border);white-space:pre-wrap;font-size:12px;line-height:1.5}}
+.panel .close{{float:right;background:none;border:none;color:var(--muted);cursor:pointer;font-size:18px}}
+.compliance{{padding:12px 20px;background:var(--card);border-top:1px solid var(--border);font-size:11px;color:var(--muted);line-height:1.6}}
+</style></head><body>
+<div class="banner">
+ <span>🏥 INTERNAL — Healthcare & Wellness Sales Targeting — Not For Public Distribution</span>
+ <span style="font-size:11px;opacity:0.8">Generated {generated}</span>
+</div>
+<div class="hd">
+ <div><h1>Healthcare & Wellness — Paid-Ad Pixel Detection</h1>
+ <div class="sub">{total} healthcare/wellness orgs with at least one tracking pixel · salons, chiropractic, dental, TCM, beauty</div></div>
+ <button class="theme-tog" onclick="toggleTheme()" id="themeBtn">☀ light</button>
+</div>
+<div class="stats">
+ <div class="stat"><div class="v">{total}</div><div class="l">Total</div></div>
+ <div class="stat"><div class="v" style="color:#88ccff">{tier_counts.get('TIER1_HEAVY',0)}</div><div class="l">TIER1 (3+)</div></div>
+ <div class="stat"><div class="v" style="color:#dddd80">{tier_counts.get('TIER2_MODERATE',0)}</div><div class="l">TIER2 (2)</div></div>
+ <div class="stat"><div class="v">{tier_counts.get('TIER3_SINGLE',0)}</div><div class="l">TIER3 (1)</div></div>
+</div>
+<div class="controls">
+ <input type="text" id="filter" placeholder="Filter by name, type, or city…" oninput="render()">
+ <label><input type="checkbox" data-tier="TIER1_HEAVY" checked onchange="render()"> TIER1</label>
+ <label><input type="checkbox" data-tier="TIER2_MODERATE" checked onchange="render()"> TIER2</label>
+ <label><input type="checkbox" data-tier="TIER3_SINGLE" checked onchange="render()"> TIER3</label>
+ <span id="visible-count" style="margin-left:auto;color:var(--muted);font-size:11px"></span>
+</div>
+<div class="main" id="main">
+ <div style="overflow-x:auto"><table><thead><tr>
+ <th onclick="sortBy('name')">Organization</th><th onclick="sortBy('org_type')">Type</th>
+ <th onclick="sortBy('city')">City</th><th onclick="sortBy('tier')">Tier</th>
+ <th onclick="sortBy('paid_pixels')">Pixels</th><th>Platforms</th>
+ <th>Website</th><th></th>
+ </tr></thead><tbody id="tbody"></tbody></table></div>
+ <div class="panel" id="panel" style="display:none"></div>
+</div>
+<div class="compliance">Source: tracking-pixel detection on public org websites · ad-signals enrichment Pass 1 · {generated}. Heuristic detection — no spend claims. Internal directory enrichment data only.</div>
+<script>
+const DATA = {json.dumps(data)};
+let sortKey='paid_pixels',sortAsc=false;
+function toggleTheme(){{const c=document.documentElement.getAttribute('data-theme');const n=c==='dark'?'light':'dark';document.documentElement.setAttribute('data-theme',n);localStorage.setItem('theme',n);document.getElementById('themeBtn').textContent=n==='dark'?'☀ light':'☾ dark';}}
+document.getElementById('themeBtn').textContent=document.documentElement.getAttribute('data-theme')==='dark'?'☀ light':'☾ dark';
+function sortBy(k){{if(sortKey===k)sortAsc=!sortAsc;else{{sortKey=k;sortAsc=false;}}render();}}
+function eh(s){{return String(s).replace(/[&<>\"']/g,c=>({{'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}})[c]);}}
+function render(){{
+ const q=document.getElementById('filter').value.toLowerCase();
+ const tiers=new Set([...document.querySelectorAll('input[data-tier]:checked')].map(i=>i.dataset.tier));
+ const f=DATA.filter(r=>tiers.has(r.tier)&&(!q||r.name.toLowerCase().includes(q)||r.org_type.toLowerCase().includes(q)||r.city.toLowerCase().includes(q)));
+ f.sort((a,b)=>{{const va=a[sortKey],vb=b[sortKey];let r=(typeof va==='number')?(va-vb):String(va).localeCompare(String(vb));return sortAsc?r:-r;}});
+ document.getElementById('tbody').innerHTML=f.map(r=>`<tr>
+ <td><strong>${{eh(r.name)}}</strong></td><td>${{eh(r.org_type)}}</td>
+ <td>${{eh(r.city)}}, ${{eh(r.state)}}</td><td><span class="tier-${{r.tier}}">${{r.tier.replace(/_/g,' ')}}</span></td>
+ <td>${{r.paid_pixels}}</td><td>${{r.platforms_html}}</td>
+ <td>${{r.website?`<a href="${{eh(r.website)}}" target="_blank">${{eh(r.website.replace(/^https?:\\/\\//,'').slice(0,30))}}</a>`:''}}</td>
+ <td><button class="btn-pitch" onclick='openPitch("${{r.id}}")'>🏥 Pitch</button></td>
+ </tr>`).join('');
+ document.getElementById('visible-count').textContent=`${{f.length}} of ${{DATA.length}}`;
+}}
+function openPitch(id){{
+ const r=DATA.find(x=>x.id===id);if(!r)return;
+ document.getElementById('main').classList.add('with-panel');
+ const panel=document.getElementById('panel');panel.style.display='block';
+ const platList=r.platforms.length?r.platforms.join(', '):'(none)';
+ panel.innerHTML=`<button class="close" onclick="closePitch()">×</button>
+ <h2>${{eh(r.name)}}</h2>
+ <div style="font-size:11px;color:var(--muted);margin-bottom:12px">${{eh(r.org_type)}} · ${{eh(r.city)}}, ${{eh(r.state)}} · <span class="tier-${{r.tier}}">${{r.tier.replace(/_/g,' ')}}</span></div>
+ <div style="margin-bottom:14px">${{r.platforms_html}}</div>
+ <div class="pitch-script">Hi ${{eh(r.name)}} team —
+
+We're building a directory of healthcare and wellness businesses across CA and noticed your site is running tracking pixels for ${{r.platforms.length}} ad platform${{r.platforms.length!==1?'s':''}} (${{platList}}).
+
+We're talking with practices already investing in their digital channels to compare notes — what's working in your channel mix, what intake-form conversion looks like, and where attribution is breaking down.
+
+10 minutes to swap notes? Happy to share what we're seeing across the cohort.
+
+— [Steve]
+
+[CRIB] platforms: ${{platList}} · pixels: ${{r.paid_pixels}} · website: ${{eh(r.website||'N/A')}} · NO spend claims, peer-cohort framing only.</div>`;
+}}
+function closePitch(){{document.getElementById('main').classList.remove('with-panel');document.getElementById('panel').style.display='none';}}
+render();
+</script></body></html>"""
+
+with open(OUT_PATH,'w') as f:
+ f.write(HTML)
+print(f'Wrote {OUT_PATH} · {len(HTML):,} bytes · {total} orgs')
diff --git a/scripts/cache-healthcare-templates.js b/scripts/cache-healthcare-templates.js
new file mode 100644
index 0000000..fe49da1
--- /dev/null
+++ b/scripts/cache-healthcare-templates.js
@@ -0,0 +1,89 @@
+#!/usr/bin/env node
+/**
+ * Cache top-rated healthcare-practice site bodies as structural templates
+ * for mockup generation. Adapted from lawyer-directory-builder/public/
+ * mockup-templates/top12.json + firm-*.body.txt pattern.
+ *
+ * Run once (or weekly):
+ * node scripts/cache-healthcare-templates.js
+ *
+ * Outputs:
+ * data/site-templates/top10.json — manifest [{id,name,site,score,body_file}]
+ * data/site-templates/<id>.body.txt — 6KB structural HTML excerpt per template
+ *
+ * The body files are scripts/styles-stripped HTML excerpts intended as
+ * "section ordering + layout density" inspiration for the LLM. The LLM is
+ * instructed to ignore the template's colors/fonts and apply our aesthetic.
+ */
+const fs = require('fs');
+const path = require('path');
+const cheerio = require('cheerio');
+const { fetch } = require('undici');
+
+const OUT = path.resolve(__dirname, '../data/site-templates');
+fs.mkdirSync(OUT, { recursive: true });
+
+// Curated top-rated healthcare practice landing pages — mix of
+// big-system marketing pages, modern primary-care brands, and DTC telehealth.
+// Score is a hand-assigned proxy for SEO+UX quality (90-100).
+const TEMPLATES = [
+ { id: 'mayo', name: 'Mayo Clinic', site: 'https://www.mayoclinic.org/', score: 100 },
+ { id: 'cleveland', name: 'Cleveland Clinic', site: 'https://my.clevelandclinic.org/', score: 100 },
+ { id: 'onemedical', name: 'One Medical', site: 'https://www.onemedical.com/', score: 98 },
+ { id: 'forward', name: 'Forward Health', site: 'https://goforward.com/', score: 95 },
+ { id: 'carbon', name: 'Carbon Health', site: 'https://carbonhealth.com/', score: 95 },
+ { id: 'parsley', name: 'Parsley Health', site: 'https://www.parsleyhealth.com/', score: 95 },
+ { id: 'tia', name: 'Tia', site: 'https://www.asktia.com/', score: 95 },
+ { id: 'oak', name: 'Oak Street Health', site: 'https://www.oakstreethealth.com/', score: 92 },
+ { id: 'galileo', name: 'Galileo Health', site: 'https://galileo.io/', score: 90 },
+ { id: 'hims', name: 'Hims', site: 'https://www.hims.com/', score: 90 },
+];
+
+const UA = 'pd-template-cache/0.1 (research; contact: steve@designerwallcoverings.com)';
+const MAX_BODY = 6500; // chars — gemma3:12b context allows more, this is the LLM-input cap
+
+function stripToStructure(html) {
+ const $ = cheerio.load(html);
+ $('script, style, noscript, link, meta, head, iframe, svg').remove();
+ // Drop attributes that are noise for structural inspiration.
+ $('*').each((_, el) => {
+ if (el.type !== 'tag') return;
+ const keep = ['class', 'href', 'role'];
+ Object.keys(el.attribs || {}).forEach(k => { if (!keep.includes(k)) delete el.attribs[k]; });
+ });
+ // Cap class strings to first 60 chars to keep file size sane.
+ $('[class]').each((_, el) => {
+ el.attribs.class = String(el.attribs.class || '').slice(0, 60);
+ });
+ const body = $('body').html() || $.html();
+ return body
+ .replace(/<!--[\s\S]*?-->/g, '')
+ .replace(/\s+/g, ' ')
+ .replace(/>\s+</g, '><')
+ .trim()
+ .slice(0, MAX_BODY);
+}
+
+async function main() {
+ const manifest = [];
+ for (const t of TEMPLATES) {
+ process.stdout.write(`[tpl] ${t.id.padEnd(12)} ${t.site} ... `);
+ try {
+ const r = await fetch(t.site, { headers: { 'User-Agent': UA, Accept: 'text/html' }, signal: AbortSignal.timeout(15_000), redirect: 'follow' });
+ if (!r.ok) { console.log(`HTTP ${r.status}`); continue; }
+ const html = await r.text();
+ const body = stripToStructure(html);
+ if (body.length < 500) { console.log('body too small, skip'); continue; }
+ const file = `${t.id}.body.txt`;
+ fs.writeFileSync(path.join(OUT, file), body);
+ manifest.push({ id: t.id, name: t.name, site: t.site, score: t.score, body_file: file, body_size: body.length });
+ console.log(`${body.length}B`);
+ } catch (e) {
+ console.log(`ERR ${e.message}`);
+ }
+ }
+ fs.writeFileSync(path.join(OUT, 'top10.json'), JSON.stringify(manifest, null, 2));
+ console.log(`\n[tpl] cached ${manifest.length}/${TEMPLATES.length} → ${OUT}/top10.json`);
+}
+
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/check_alt_domains.js b/scripts/check_alt_domains.js
new file mode 100644
index 0000000..02a1be7
--- /dev/null
+++ b/scripts/check_alt_domains.js
@@ -0,0 +1,204 @@
+#!/usr/bin/env node
+/**
+ * Alternate-domain pass — for prospects whose primary slug.com was taken,
+ * try a small set of variants and TLDs and persist the FIRST available match.
+ *
+ * Variants per name (in priority order):
+ * 1. <slug>.com (already tried in primary pass — skip)
+ * 2. <slug-with-stopwords>.com (e.g. "thejointchiro" — keep "the")
+ * 3. <slug>-<city>.com (e.g. "miramassage-malibu.com")
+ * 4. <slug>la.com (e.g. "miramassagela.com")
+ * 5. <slug>.salon / .clinic / .care / .studio (TLD by category)
+ * 6. <slug>.co
+ * 7. <slug>.net
+ *
+ * Stops at the first available result. Free RDAP for .com/.net/.co/etc;
+ * RDAP for niche TLDs falls back to a polite TCP WHOIS lookup, but we keep
+ * the lookup stack small to respect public infra.
+ *
+ * Run:
+ * node scripts/check_alt_domains.js # all
+ * LIMIT=200 node scripts/check_alt_domains.js
+ */
+const { fetch } = require('undici');
+const { pool, query } = require('../agents/shared/db');
+
+const RPS = parseFloat(process.env.RDAP_RPS || '5');
+const UA = process.env.USER_AGENT
+ || 'ProfessionalDirectoryBot/0.1 (small-biz prospect tool; contact: steve@designerwallcoverings.com)';
+
+// IANA RDAP bootstrap → these TLDs all work via the same Verisign-style endpoints.
+const RDAP_BY_TLD = {
+ com: 'https://rdap.verisign.com/com/v1/domain',
+ net: 'https://rdap.verisign.com/net/v1/domain',
+ co: 'https://rdap.nic.co/domain',
+};
+
+const TLDS_BY_CATEGORY = {
+ cosmetology: ['salon','beauty','co','net'],
+ chiropractic: ['clinic','health','co','net'],
+ chinese_medicine: ['clinic','care','co','net'],
+ dental: ['dental','clinic','care','co','net'],
+ optometry: ['vision','clinic','care','co','net'],
+ fitness: ['fitness','studio','yoga','co','net'],
+ tattoo: ['ink','studio','co','net'],
+ pets: ['pet','vet','care','co','net'],
+};
+
+const SMALL_TYPES = [
+ 'salon','barbershop','nail_salon','beauty_supply','spa',
+ 'chiropractor_office',
+ 'acupuncture_clinic','tcm_clinic','tcm_herbalist',
+ 'dental_office','optometrist_office','optical_shop',
+ 'yoga_studio','pilates_studio','gym',
+ 'tattoo_studio','piercing_studio',
+ 'vet_clinic','pet_grooming','pet_shop',
+];
+
+function categoryOf(type) {
+ if (['salon','barbershop','nail_salon','beauty_supply','spa'].includes(type)) return 'cosmetology';
+ if (type === 'chiropractor_office') return 'chiropractic';
+ if (['acupuncture_clinic','tcm_clinic','tcm_herbalist'].includes(type)) return 'chinese_medicine';
+ if (type === 'dental_office') return 'dental';
+ if (['optometrist_office','optical_shop'].includes(type)) return 'optometry';
+ if (['yoga_studio','pilates_studio','gym'].includes(type)) return 'fitness';
+ if (['tattoo_studio','piercing_studio'].includes(type)) return 'tattoo';
+ if (['vet_clinic','pet_grooming','pet_shop'].includes(type)) return 'pets';
+ return null;
+}
+
+const STOP_WORDS = new Set([
+ 'the','and','of','for','llc','inc','co','company','corp','corporation',
+ 'group','center','centre','services','office',
+]);
+
+function slugifyKeepStop(name) {
+ if (!name) return null;
+ let s = name.toLowerCase().replace(/&/g,' and ').replace(/[^a-z0-9\s-]/g,' ').trim();
+ const tokens = s.split(/[\s-]+/).filter(Boolean);
+ const joined = tokens.join('');
+ if (joined.length < 3 || joined.length > 63) return null;
+ if (!/^[a-z0-9]/.test(joined) || !/[a-z0-9]$/.test(joined)) return null;
+ return joined;
+}
+
+function slugifyDrop(name) {
+ if (!name) return null;
+ let s = name.toLowerCase().replace(/&/g,' and ').replace(/[^a-z0-9\s-]/g,' ').trim();
+ const tokens = s.split(/[\s-]+/).filter(t => t && !STOP_WORDS.has(t));
+ if (tokens.length === 0) return null;
+ const joined = tokens.join('');
+ if (joined.length < 3 || joined.length > 63) return null;
+ if (!/^[a-z0-9]/.test(joined) || !/[a-z0-9]$/.test(joined)) return null;
+ return joined;
+}
+
+function citySlug(city) {
+ if (!city) return null;
+ const c = city.toLowerCase().replace(/[^a-z0-9]/g, '');
+ return c || null;
+}
+
+function buildCandidates(name, city, type) {
+ const cat = categoryOf(type);
+ const niche = TLDS_BY_CATEGORY[cat] || ['co','net'];
+ const slugA = slugifyDrop(name); // already-tried slug
+ const slugB = slugifyKeepStop(name); // includes stop words
+ const cs = citySlug(city);
+
+ const out = [];
+ // 1. Variants on .com first (cheapest TLD)
+ if (slugB && slugB !== slugA) out.push({ slug: slugB, tld: 'com' });
+ if (slugA && cs) out.push({ slug: `${slugA}-${cs}`, tld: 'com' });
+ if (slugA) out.push({ slug: `${slugA}la`, tld: 'com' });
+ // 2. Niche TLDs of category for the canonical slug
+ for (const t of niche) {
+ if (slugA) out.push({ slug: slugA, tld: t });
+ }
+ // Dedupe.
+ const seen = new Set();
+ return out.filter(c => {
+ const k = `${c.slug}.${c.tld}`;
+ if (seen.has(k)) return false;
+ seen.add(k);
+ return c.slug && c.tld;
+ });
+}
+
+let lastFetch = 0;
+async function gate() {
+ const minInterval = 1000 / RPS;
+ const wait = Math.max(0, lastFetch + minInterval - Date.now());
+ if (wait > 0) await new Promise(r => setTimeout(r, wait));
+ lastFetch = Date.now();
+}
+
+async function checkRdap(slug, tld) {
+ const base = RDAP_BY_TLD[tld];
+ if (!base) return null; // no RDAP endpoint for this TLD; skip silently
+ await gate();
+ try {
+ const res = await fetch(`${base}/${slug}.${tld}`, {
+ headers: { 'User-Agent': UA, Accept: 'application/json' },
+ signal: AbortSignal.timeout(10000),
+ });
+ if (res.status === 404) return true;
+ if (res.status === 200) return false;
+ if (res.status === 429) { await new Promise(r => setTimeout(r, 5000)); return null; }
+ return null;
+ } catch (_) { return null; }
+}
+
+async function loadCandidates(limit) {
+ const r = await query(`
+ SELECT id, name, city, type
+ FROM organizations
+ WHERE domain_available = FALSE
+ AND type = ANY($1::text[])
+ AND NOT is_chain
+ AND opted_out = FALSE
+ ORDER BY id
+ ${limit ? `LIMIT ${Number(limit)}` : ''}
+ `, [SMALL_TYPES]);
+ return r.rows;
+}
+
+async function main() {
+ const limit = process.env.LIMIT ? Number(process.env.LIMIT) : null;
+ const cands = await loadCandidates(limit);
+ console.log(`[alt] candidates=${cands.length} rps=${RPS}`);
+
+ let upgraded = 0, examined = 0, stillTaken = 0;
+ for (const c of cands) {
+ const variants = buildCandidates(c.name, c.city, c.type);
+ let foundDomain = null;
+ for (const v of variants) {
+ const result = await checkRdap(v.slug, v.tld);
+ if (result === true) { foundDomain = `${v.slug}.${v.tld}`; break; }
+ }
+ examined++;
+ if (foundDomain) {
+ await query(
+ `UPDATE organizations
+ SET suggested_domain = $2,
+ domain_available = TRUE,
+ domain_checked_at = NOW()
+ WHERE id = $1`,
+ [c.id, foundDomain]
+ );
+ upgraded++;
+ } else {
+ stillTaken++;
+ }
+ if (examined % 50 === 0) console.log(`[alt] examined=${examined} upgraded=${upgraded} stillTaken=${stillTaken}`);
+ }
+
+ console.log(`[alt] done. examined=${examined} upgraded=${upgraded} stillTaken=${stillTaken}`);
+ await pool.end();
+}
+
+main().catch(async (err) => {
+ console.error('[alt] fatal:', err);
+ try { await pool.end(); } catch (_) {}
+ process.exit(1);
+});
diff --git a/scripts/check_prospect_domains.js b/scripts/check_prospect_domains.js
new file mode 100644
index 0000000..68251ce
--- /dev/null
+++ b/scripts/check_prospect_domains.js
@@ -0,0 +1,157 @@
+#!/usr/bin/env node
+/**
+ * Domain availability check for small-biz prospects.
+ *
+ * For each org with no website, slugify the name → candidate.com, check
+ * availability via Verisign RDAP (free, no key, public). Persist back to
+ * organizations.suggested_domain / domain_available / domain_checked_at.
+ *
+ * RDAP semantics:
+ * 404 → available (or never registered; unallocated)
+ * 200 → registered
+ * 429 → rate-limited (retry with backoff)
+ *
+ * Run:
+ * node scripts/check_prospect_domains.js # all prospects, all categories
+ * LIMIT=200 node scripts/check_prospect_domains.js # small batch
+ * CATEGORY=cosmetology node scripts/check_prospect_domains.js
+ */
+const { fetch } = require('undici');
+const { pool, query } = require('../agents/shared/db');
+
+const CATEGORY_TYPES = {
+ cosmetology: ['salon','barbershop','nail_salon','beauty_supply','spa'],
+ chiropractic: ['chiropractor_office'],
+ chinese_medicine:['acupuncture_clinic','tcm_clinic','tcm_herbalist'],
+ dental: ['dental_office'],
+ optometry: ['optometrist_office','optical_shop'],
+ fitness: ['yoga_studio','pilates_studio','gym'],
+ tattoo: ['tattoo_studio','piercing_studio'],
+ pets: ['vet_clinic','pet_grooming','pet_shop'],
+};
+
+const RDAP_COM = 'https://rdap.verisign.com/com/v1/domain';
+const RPS = parseFloat(process.env.RDAP_RPS || '5'); // 5 req/s — Verisign tolerates it
+const USER_AGENT = process.env.USER_AGENT
+ || 'ProfessionalDirectoryBot/0.1 (small-biz prospect tool; contact: steve@designerwallcoverings.com)';
+
+const STOP_WORDS = new Set([
+ 'the','and','of','for','llc','inc','co','company','corp','corporation',
+ 'studio','group','center','centre','clinic','salon','spa','salon','shop','services','office',
+]);
+
+function slugify(name) {
+ if (!name) return null;
+ // 1) take alphanumerics only, treat hyphen/space as boundary
+ let s = name.toLowerCase()
+ .replace(/&/g, ' and ')
+ .replace(/[^a-z0-9\s-]/g, ' ')
+ .trim();
+ // 2) drop common stop words but keep meaningful tokens
+ const tokens = s.split(/[\s-]+/).filter(t => t && !STOP_WORDS.has(t));
+ if (tokens.length === 0) return null;
+ // 3) join, drop the leading 'a'/'i' digits etc
+ let joined = tokens.join('');
+ // 4) cap at 63 chars (RFC 1035 label limit)
+ if (joined.length < 3 || joined.length > 63) return null;
+ // 5) start/end alphanumeric
+ if (!/^[a-z0-9]/.test(joined) || !/[a-z0-9]$/.test(joined)) return null;
+ return joined;
+}
+
+let lastFetch = 0;
+async function gate() {
+ const minInterval = 1000 / RPS;
+ const wait = Math.max(0, lastFetch + minInterval - Date.now());
+ if (wait > 0) await new Promise(r => setTimeout(r, wait));
+ lastFetch = Date.now();
+}
+
+async function checkRdap(slug) {
+ await gate();
+ const url = `${RDAP_COM}/${slug}.com`;
+ try {
+ const res = await fetch(url, {
+ headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
+ signal: AbortSignal.timeout(10000),
+ });
+ if (res.status === 404) return { available: true };
+ if (res.status === 200) return { available: false };
+ if (res.status === 429) {
+ await new Promise(r => setTimeout(r, 5000));
+ return null; // signal retry-later
+ }
+ return null;
+ } catch (e) {
+ return null;
+ }
+}
+
+async function loadProspects(limit, category) {
+ const where = ["(website IS NULL OR website = '')",
+ "name IS NOT NULL",
+ "domain_checked_at IS NULL",
+ "opted_out = FALSE"];
+ const params = []; let i = 1;
+ if (category && CATEGORY_TYPES[category]) {
+ where.push(`type = ANY($${i}::text[])`); params.push(CATEGORY_TYPES[category]); i++;
+ } else {
+ // Default: only the small-biz types
+ const allTypes = Object.values(CATEGORY_TYPES).flat();
+ where.push(`type = ANY($${i}::text[])`); params.push(allTypes); i++;
+ }
+ const r = await query(
+ `SELECT id, name FROM organizations WHERE ${where.join(' AND ')} ORDER BY id LIMIT ${Number(limit) || 1000000}`,
+ params
+ );
+ return r.rows;
+}
+
+async function persist(orgId, slug, available) {
+ await query(`
+ UPDATE organizations
+ SET suggested_domain = $2,
+ domain_available = $3,
+ domain_checked_at = NOW()
+ WHERE id = $1
+ `, [orgId, slug ? `${slug}.com` : null, available]);
+}
+
+async function main() {
+ const limit = process.env.LIMIT ? Number(process.env.LIMIT) : null;
+ const category = process.env.CATEGORY || null;
+ console.log(`[domains] mode=${category || 'all'} limit=${limit || 'all'} rps=${RPS}`);
+
+ const prospects = await loadProspects(limit, category);
+ console.log(`[domains] candidates=${prospects.length}`);
+
+ let checked = 0, available = 0, taken = 0, skipped = 0, errors = 0;
+ for (const p of prospects) {
+ const slug = slugify(p.name);
+ if (!slug) { await persist(p.id, null, null); skipped++; continue; }
+
+ let result = await checkRdap(slug);
+ if (result === null) {
+ // One retry on transient.
+ await new Promise(r => setTimeout(r, 1000));
+ result = await checkRdap(slug);
+ }
+ if (result === null) { errors++; continue; }
+ await persist(p.id, slug, result.available);
+ checked++;
+ if (result.available) available++; else taken++;
+
+ if (checked % 50 === 0) {
+ console.log(`[domains] checked=${checked} available=${available} taken=${taken} skipped=${skipped} errors=${errors}`);
+ }
+ }
+
+ console.log(`[domains] done. checked=${checked} available=${available} taken=${taken} skipped=${skipped} errors=${errors}`);
+ await pool.end();
+}
+
+main().catch(async (err) => {
+ console.error('[domains] fatal:', err);
+ try { await pool.end(); } catch (_) {}
+ process.exit(1);
+});
diff --git a/scripts/doctor-agent-loop.sh b/scripts/doctor-agent-loop.sh
new file mode 100755
index 0000000..cb5c928
--- /dev/null
+++ b/scripts/doctor-agent-loop.sh
@@ -0,0 +1,141 @@
+#!/bin/zsh
+# Doctor Agent — overnight build supervisor.
+#
+# Runs in a tight loop:
+# 1. v5 mockup gen on next 25 unclaimed orgs (skips already-generated)
+# 2. quality report tail
+# 3. pd-* pm2 health check (one auto-restart per process per night)
+# 4. sleep N seconds, repeat
+#
+# Cap: stop at $LOOP_HOURS hours OR when --once flag passed.
+# Logs: $PD/logs/doctor-agent-loop.{out,err}.log
+#
+# Trigger:
+# - manual: ./scripts/doctor-agent-loop.sh
+# - launchd: com.steve.doctor-agent-overnight (dusk-to-dawn)
+
+set -u
+PD="${PD:-/Users/stevestudio2/Projects/professional-directory}"
+cd "$PD"
+
+LOOP_HOURS="${LOOP_HOURS:-10}" # default 10h overnight
+BATCH_SIZE="${BATCH_SIZE:-25}" # orgs per pass
+SLEEP_SECONDS="${SLEEP_SECONDS:-30}" # cool-down between passes
+LLM_TIMEOUT_MS="${LLM_TIMEOUT_MS:-540000}" # 9 min/variant
+MODEL_HOST_PRIMARY="${MODEL_HOST_PRIMARY:-http://127.0.0.1:11434}"
+MODEL_HOST_FALLBACK="${MODEL_HOST_FALLBACK:-http://192.168.1.133:11434}"
+PRIMARY_MODEL="${PRIMARY_MODEL:-gemma3:27b}"
+FALLBACK_MODEL="${FALLBACK_MODEL:-qwen3:32b}"
+
+LOG_DIR="$PD/logs"
+mkdir -p "$LOG_DIR"
+STATE="$LOG_DIR/doctor-agent-loop.state"
+HEARTBEAT="$LOG_DIR/doctor-agent-loop.heartbeat"
+OUT_LOG="$LOG_DIR/doctor-agent-loop.log"
+
+START_TS=$(date +%s)
+DEADLINE=$((START_TS + LOOP_HOURS * 3600))
+
+log() { echo "[doctor-agent $(date +%H:%M:%S)] $*" | tee -a "$OUT_LOG"; }
+beat() { date +%s > "$HEARTBEAT"; }
+
+# Pick the live ollama host. Prefer MS1; fall back to MS2 if MS1 is unreachable.
+pick_host() {
+ if curl -m 4 -s -f "$MODEL_HOST_PRIMARY/api/tags" >/dev/null 2>&1; then
+ echo "$MODEL_HOST_PRIMARY|$PRIMARY_MODEL"
+ elif curl -m 4 -s -f "$MODEL_HOST_FALLBACK/api/tags" >/dev/null 2>&1; then
+ echo "$MODEL_HOST_FALLBACK|$FALLBACK_MODEL"
+ else
+ echo "DOWN|"
+ fi
+}
+
+# Health-check the pd-* PM2 fleet. Auto-restart up to once per process per loop run.
+check_pm2() {
+ local restarted_marker="$LOG_DIR/doctor-agent-loop.restarted-this-run"
+ command -v pm2 >/dev/null || { log "pm2 not found in PATH; skipping health check"; return; }
+ local statuses=$(pm2 jlist 2>/dev/null | python3 -c '
+import json,sys
+try: d=json.load(sys.stdin)
+except: sys.exit(0)
+for p in d:
+ n=p.get("name","")
+ if n.startswith("pd-"):
+ s=p.get("pm2_env",{}).get("status","?")
+ print(f"{n} {s}")
+' 2>/dev/null)
+ echo "$statuses" | while read -r name pmstatus; do
+ [ -z "$name" ] && continue
+ if [ "$pmstatus" != "online" ]; then
+ if grep -q "^$name$" "$restarted_marker" 2>/dev/null; then
+ log "WARN $name is $pmstatus (already auto-restarted this run; not retrying — investigate)"
+ else
+ log "AUTO-RESTART $name (status=$pmstatus)"
+ pm2 restart "$name" >/dev/null 2>&1 || true
+ echo "$name" >> "$restarted_marker"
+ fi
+ fi
+ done
+}
+
+# Tail-summarize last 25 lines of the v5 quality report.
+quality_summary() {
+ local qa="$PD/data/mockups/_v5_quality_report.jsonl"
+ [ -f "$qa" ] || { log "no quality report yet"; return; }
+ python3 - <<'PYEOF' "$qa"
+import json,sys
+lines=open(sys.argv[1]).read().splitlines()[-50:]
+ok=tn=0
+issues={}
+for ln in lines:
+ try: r=json.loads(ln)
+ except: continue
+ if r.get("ok"): ok+=1
+ else:
+ tn+=1
+ for i in r.get("issues",[]): issues[i]=issues.get(i,0)+1
+total=ok+tn
+top=sorted(issues.items(),key=lambda x:-x[1])[:3]
+print(f"recent: {ok}/{total} ok ({(100*ok/total) if total else 0:.0f}%) top-issues: {top}")
+PYEOF
+}
+
+run_one_pass() {
+ beat
+ local pick=$(pick_host)
+ local host="${pick%%|*}"
+ local model="${pick##*|}"
+ if [ "$host" = "DOWN" ]; then
+ log "BOTH OLLAMA HOSTS DOWN — sleeping 5min"
+ sleep 300
+ return
+ fi
+ log "pass on $host model=$model batch=$BATCH_SIZE"
+ MODEL_HOST="$host" \
+ LLM_MOCKUP_MODEL="$model" \
+ LIMIT="$BATCH_SIZE" \
+ VARIANTS="a,b,c" \
+ LLM_TIMEOUT_MS="$LLM_TIMEOUT_MS" \
+ STRICT="${STRICT:-0}" \
+ MAX_RETRIES="${MAX_RETRIES:-2}" \
+ node "$PD/scripts/generate-mockups-v5.js" 2>&1 | tee -a "$OUT_LOG"
+ quality_summary | tee -a "$OUT_LOG"
+ check_pm2
+}
+
+if [ "${1:-}" = "--once" ]; then
+ log "RUN-ONCE mode"
+ run_one_pass
+ exit 0
+fi
+
+log "LOOP started — deadline in ${LOOP_HOURS}h"
+rm -f "$LOG_DIR/doctor-agent-loop.restarted-this-run"
+
+while [ $(date +%s) -lt $DEADLINE ]; do
+ run_one_pass
+ log "sleeping ${SLEEP_SECONDS}s"
+ sleep "$SLEEP_SECONDS"
+done
+
+log "LOOP done — deadline reached"
diff --git a/scripts/doctor-nightly-v5-check.sh b/scripts/doctor-nightly-v5-check.sh
new file mode 100755
index 0000000..f0d485a
--- /dev/null
+++ b/scripts/doctor-nightly-v5-check.sh
@@ -0,0 +1,111 @@
+#!/bin/bash
+# Doctor v5 nightly run check — runs at 09:00 PT daily, audits last night's mockup gen.
+# Wired to com.steve.doctor-nightly-v5-check.
+set -uo pipefail
+
+PD="${PD:-/Users/stevestudio2/Projects/professional-directory}"
+cd "$PD" || exit 1
+
+TODAY=$(date +%Y-%m-%d)
+LOG_TXT="$PD/logs/doctor-nightly-v5-check.log"
+mkdir -p "$PD/logs"
+
+ts_local() { date +"%H:%M:%S"; }
+log() { echo "[v5-check $(ts_local)] $*" | tee -a "$LOG_TXT"; }
+
+# ----- Last nightly run logs -----
+NIGHTLY_OUT="$PD/logs/mockups-nightly.out.log"
+NIGHTLY_ERR="$PD/logs/mockups-nightly.err.log"
+
+OUT_AGE_MIN=-1
+ERR_LINES=0
+ERR_TAIL=""
+if [ -f "$NIGHTLY_OUT" ]; then
+ OUT_AGE_MIN=$(( ( $(date +%s) - $(stat -f %m "$NIGHTLY_OUT") ) / 60 ))
+fi
+if [ -f "$NIGHTLY_ERR" ]; then
+ ERR_LINES=$(wc -l < "$NIGHTLY_ERR" | tr -d ' ')
+ ERR_TAIL=$(tail -20 "$NIGHTLY_ERR" 2>/dev/null)
+fi
+
+# ----- Mockup files written between 01:00 and 09:00 today -----
+MOCK_DIR="$PD/data/mockups"
+WROTE_TODAY=$(find "$MOCK_DIR" -maxdepth 1 -name '*.html' -newermt "${TODAY} 01:00" ! -newermt "${TODAY} 09:00" 2>/dev/null | wc -l | tr -d ' ')
+TAINTED_TODAY=$(find "$MOCK_DIR" -maxdepth 1 -name '*tainted*' -newermt "${TODAY} 01:00" ! -newermt "${TODAY} 09:00" 2>/dev/null | wc -l | tr -d ' ')
+
+# ----- Run the audit on today's files only -----
+AUDIT_REPORT=""
+if [ "$WROTE_TODAY" -gt 0 ]; then
+ AUDIT_REPORT=$(node -e "
+const fs = require('fs');
+const path = require('path');
+const dir = '$MOCK_DIR';
+const today = '$TODAY';
+const start = Date.parse(today + 'T01:00:00') / 1000;
+const end = Date.parse(today + 'T09:00:00') / 1000;
+const all = fs.readdirSync(dir).filter(f => f.endsWith('.html'))
+ .map(f => ({ f, m: fs.statSync(path.join(dir,f)).mtimeMs / 1000 }))
+ .filter(x => x.m >= start && x.m <= end);
+const tainted = all.filter(x => x.f.includes('tainted'));
+const clean = all.filter(x => !x.f.includes('tainted'));
+console.log(JSON.stringify({ total: all.length, clean: clean.length, tainted: tainted.length, taintRate: all.length ? (tainted.length / all.length * 100).toFixed(1) + '%' : 'n/a' }));
+" 2>&1)
+fi
+
+# ----- snapshot-mockups.js run? Look for its output marker -----
+SNAPSHOT_RAN="unknown"
+if [ -f "$NIGHTLY_OUT" ]; then
+ if grep -q "snapshot-mockups" "$NIGHTLY_OUT" 2>/dev/null || tail -50 "$NIGHTLY_OUT" 2>/dev/null | grep -qi "snapshot"; then
+ SNAPSHOT_RAN="yes"
+ else
+ SNAPSHOT_RAN="not-found-in-log"
+ fi
+fi
+
+# ----- MS1 health right now -----
+MS1_STATUS=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 http://192.168.1.133:11434/api/tags || echo "000")
+QWEN_LOADED=$(curl -s --max-time 5 http://192.168.1.133:11434/api/tags 2>/dev/null | python3 -c 'import json,sys; d=json.load(sys.stdin); print("yes" if "qwen3:32b" in [m["name"] for m in d.get("models",[])] else "no")' 2>/dev/null || echo "unknown")
+
+# ----- Body -----
+BODY="Doctor v5 nightly check — $TODAY
+
+Run logs:
+- mockups-nightly.out.log mtime: $OUT_AGE_MIN min ago
+- mockups-nightly.err.log: $ERR_LINES total lines
+
+Files generated 01:00-09:00 today: $WROTE_TODAY
+- Tainted: $TAINTED_TODAY
+- Audit: ${AUDIT_REPORT:-no files to audit}
+
+snapshot-mockups.js ran: $SNAPSHOT_RAN
+MS1 ollama right now: $MS1_STATUS (qwen3:32b loaded: $QWEN_LOADED)
+
+Last 20 lines of err log:
+$ERR_TAIL
+
+Auto-generated by com.steve.doctor-nightly-v5-check"
+
+log "files=$WROTE_TODAY tainted=$TAINTED_TODAY out_age=${OUT_AGE_MIN}min err_lines=$ERR_LINES MS1=$MS1_STATUS qwen=$QWEN_LOADED"
+
+# ----- Email Steve via George -----
+python3 -c '
+import json, urllib.request, sys, os
+body = sys.stdin.read()
+payload = json.dumps({
+ "to": "steveabramsdesigns@gmail.com",
+ "subject": "Doctor v5 nightly check — " + os.environ.get("TODAY", ""),
+ "body": body
+}).encode()
+req = urllib.request.Request(
+ "http://localhost:9850/api/send?account=info",
+ data=payload,
+ headers={"Content-Type": "application/json"}
+)
+try:
+ r = urllib.request.urlopen(req, timeout=10)
+ print("george:", r.status)
+except Exception as e:
+ print("george-err:", e, file=sys.stderr)
+' TODAY="$TODAY" <<<"$BODY" >> "$LOG_TXT" 2>&1
+
+exit 0
diff --git a/scripts/doctor-progress-tick.sh b/scripts/doctor-progress-tick.sh
new file mode 100755
index 0000000..5949c49
--- /dev/null
+++ b/scripts/doctor-progress-tick.sh
@@ -0,0 +1,151 @@
+#!/bin/bash
+# Doctor day-time progress tick.
+# Snapshot the doctor project, log a JSON line, email Steve via George iff blocking.
+# Wired to com.steve.doctor-progress-day (every 2h, 11/13/15/17/19/21 PT).
+set -uo pipefail
+
+PD="${PD:-/Users/stevestudio2/Projects/professional-directory}"
+cd "$PD" || exit 1
+
+LOG_JSONL="$PD/data/_doctor_progress_log.jsonl"
+LOG_TXT="$PD/logs/doctor-progress-tick.log"
+mkdir -p "$PD/data" "$PD/logs"
+
+ts_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; }
+ts_local() { date +"%H:%M:%S"; }
+log() { echo "[doctor-tick $(ts_local)] $*" | tee -a "$LOG_TXT"; }
+
+DB="doctor_professional_directory"
+psqlq() { psql -d "$DB" -tA -F'|' -c "$1" 2>/dev/null; }
+
+# ----- pm2 snapshot for the 7 pd-* agents -----
+PM2_LINE=$(pm2 jlist 2>/dev/null | python3 -c '
+import json, sys
+try:
+ d = json.load(sys.stdin)
+except Exception:
+ print("err|0|0|0"); sys.exit(0)
+pd = [p for p in d if p.get("name","").startswith("pd-")]
+online = [p for p in pd if p.get("pm2_env",{}).get("status")=="online"]
+restart_total = sum(int(p.get("pm2_env",{}).get("restart_time",0)) for p in pd)
+mem_mb = sum(int(p.get("monit",{}).get("memory",0)) for p in pd) // (1024*1024)
+print(f"ok|{len(pd)}|{len(online)}|{restart_total}|{mem_mb}")
+')
+PD_TOTAL=$(echo "$PM2_LINE" | cut -d'|' -f2)
+PD_ONLINE=$(echo "$PM2_LINE" | cut -d'|' -f3)
+PD_RESTARTS=$(echo "$PM2_LINE" | cut -d'|' -f4)
+PD_MEM=$(echo "$PM2_LINE" | cut -d'|' -f5)
+
+# ----- pd-hawk last heartbeat (read its last log mtime) -----
+HAWK_LOG="$PD/logs/pd-hawk.log"
+if [ -f "$HAWK_LOG" ]; then
+ HAWK_AGE_MIN=$(( ( $(date +%s) - $(stat -f %m "$HAWK_LOG") ) / 60 ))
+else
+ HAWK_AGE_MIN="-1"
+fi
+
+# ----- PG row counts (single round trip) -----
+ROWS=$(psqlq "
+SELECT
+ (SELECT COUNT(*) FROM professionals),
+ (SELECT COUNT(*) FROM organizations),
+ (SELECT COUNT(*) FROM professional_locations),
+ (SELECT COUNT(*) FROM doctor_claims WHERE status IN ('pending','submitted')),
+ (SELECT COUNT(*) FROM reviews),
+ (SELECT COUNT(*) FROM scrape_jobs WHERE status IN ('queued','running')),
+ (SELECT COUNT(*) FROM scrape_jobs WHERE status = 'failed' AND started_at > NOW() - INTERVAL '24 hours');
+")
+PROS=$(echo "$ROWS" | cut -d'|' -f1); PROS=${PROS:-0}
+ORGS=$(echo "$ROWS" | cut -d'|' -f2); ORGS=${ORGS:-0}
+LOCS=$(echo "$ROWS" | cut -d'|' -f3); LOCS=${LOCS:-0}
+CLAIMS_PENDING=$(echo "$ROWS" | cut -d'|' -f4); CLAIMS_PENDING=${CLAIMS_PENDING:-0}
+REVIEWS=$(echo "$ROWS" | cut -d'|' -f5); REVIEWS=${REVIEWS:-0}
+JOBS_LIVE=$(echo "$ROWS" | cut -d'|' -f6); JOBS_LIVE=${JOBS_LIVE:-0}
+JOBS_FAILED_24H=$(echo "$ROWS" | cut -d'|' -f7); JOBS_FAILED_24H=${JOBS_FAILED_24H:-0}
+
+# ----- Mockup file counts -----
+MOCK_DIR="$PD/data/mockups"
+MOCK_CLEAN=$(find "$MOCK_DIR" -maxdepth 1 -name '*.html' ! -name '*tainted*' 2>/dev/null | wc -l | tr -d ' ')
+MOCK_TAINTED_LIVE=$(find "$MOCK_DIR" -maxdepth 1 -name '*tainted*' 2>/dev/null | wc -l | tr -d ' ')
+MOCK_QUARANTINE=$(find "$MOCK_DIR/_quarantine" -name '*.html' 2>/dev/null | wc -l | tr -d ' ')
+
+# ----- /account/claim still serving 200? -----
+CLAIM_STATUS=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 http://localhost:9876/account/claim || echo "000")
+
+# ----- MS1 health -----
+MS1_STATUS=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 http://192.168.1.133:11434/api/tags || echo "000")
+
+# ----- Decide if anything is BLOCKING (warrants email) -----
+ALERTS=()
+[ "$PD_ONLINE" = "0" ] || [ "$PD_TOTAL" = "0" ] && ALERTS+=("pd-* agents not visible (total=$PD_TOTAL online=$PD_ONLINE)")
+[ -n "$PD_TOTAL" ] && [ "$PD_TOTAL" != "0" ] && [ "$PD_ONLINE" != "$PD_TOTAL" ] && ALERTS+=("pd-* not all online: $PD_ONLINE/$PD_TOTAL")
+[ "$CLAIM_STATUS" != "200" ] && ALERTS+=("/account/claim returned $CLAIM_STATUS (expected 200)")
+[ "$MS1_STATUS" != "200" ] && ALERTS+=("MS1 ollama returned $MS1_STATUS (expected 200)")
+[ "$MOCK_TAINTED_LIVE" != "0" ] && ALERTS+=("$MOCK_TAINTED_LIVE tainted mockups in main dir (expected 0; should be quarantined)")
+[ "$JOBS_FAILED_24H" -gt 50 ] 2>/dev/null && ALERTS+=("scrape_jobs failed in last 24h: $JOBS_FAILED_24H")
+[ "$HAWK_AGE_MIN" != "-1" ] && [ "$HAWK_AGE_MIN" -gt 90 ] 2>/dev/null && ALERTS+=("pd-hawk log stale ($HAWK_AGE_MIN min)")
+
+# ----- Append JSONL log line -----
+ALERT_JSON="[]"
+if [ ${#ALERTS[@]} -gt 0 ]; then
+ ALERT_JSON=$(printf '%s\n' "${ALERTS[@]}" | python3 -c 'import json,sys; print(json.dumps([l.rstrip() for l in sys.stdin if l.strip()]))')
+fi
+
+cat >> "$LOG_JSONL" <<JSON
+{"ts":"$(ts_iso)","pd":{"total":$PD_TOTAL,"online":$PD_ONLINE,"restarts":$PD_RESTARTS,"mem_mb":$PD_MEM},"hawk_age_min":$HAWK_AGE_MIN,"pg":{"pros":$PROS,"orgs":$ORGS,"locs":$LOCS,"claims_pending":$CLAIMS_PENDING,"reviews":$REVIEWS,"jobs_live":$JOBS_LIVE,"jobs_failed_24h":$JOBS_FAILED_24H},"mockups":{"clean":$MOCK_CLEAN,"tainted_live":$MOCK_TAINTED_LIVE,"quarantine":$MOCK_QUARANTINE},"http":{"claim_page":"$CLAIM_STATUS","ms1_ollama":"$MS1_STATUS"},"alerts":$ALERT_JSON}
+JSON
+
+log "snap pd=$PD_ONLINE/$PD_TOTAL pg=$PROS/$ORGS/$LOCS claims=$CLAIMS_PENDING mockups=$MOCK_CLEAN/$MOCK_TAINTED_LIVE/Q$MOCK_QUARANTINE claim=$CLAIM_STATUS MS1=$MS1_STATUS alerts=${#ALERTS[@]}"
+
+# ----- Email George iff alerts (rate-limited: only if previous tick had no alerts OR it's been >4h since last email) -----
+LAST_EMAIL_FILE="$PD/logs/.doctor-progress-last-email"
+SHOULD_EMAIL=0
+if [ ${#ALERTS[@]} -gt 0 ]; then
+ if [ -f "$LAST_EMAIL_FILE" ]; then
+ LAST_AGE=$(( $(date +%s) - $(stat -f %m "$LAST_EMAIL_FILE") ))
+ [ "$LAST_AGE" -gt 14400 ] && SHOULD_EMAIL=1
+ else
+ SHOULD_EMAIL=1
+ fi
+fi
+
+if [ "$SHOULD_EMAIL" = "1" ]; then
+ ALERT_BODY=$(printf '%s\n' "${ALERTS[@]}")
+ BODY="Doctor project tick @ $(date +"%Y-%m-%d %H:%M %Z")
+
+ALERTS:
+$ALERT_BODY
+
+State:
+- pd-* agents: $PD_ONLINE/$PD_TOTAL online ($PD_RESTARTS total restarts, ${PD_MEM}MB)
+- pd-hawk last heartbeat: $HAWK_AGE_MIN min ago
+- PG: $PROS pros / $ORGS orgs / $LOCS locs
+- Claims pending: $CLAIMS_PENDING
+- Mockups: clean=$MOCK_CLEAN tainted_live=$MOCK_TAINTED_LIVE quarantined=$MOCK_QUARANTINE
+- /account/claim: $CLAIM_STATUS | MS1 ollama: $MS1_STATUS
+- scrape_jobs: live=$JOBS_LIVE failed_24h=$JOBS_FAILED_24H
+
+Auto-generated by com.steve.doctor-progress-day"
+
+ python3 -c '
+import json, urllib.request, sys, os
+body = sys.stdin.read()
+payload = json.dumps({
+ "to": "steveabramsdesigns@gmail.com",
+ "subject": "Doctor tick alerts — " + os.environ.get("WHEN", ""),
+ "body": body
+}).encode()
+req = urllib.request.Request(
+ "http://localhost:9850/api/send?account=info",
+ data=payload,
+ headers={"Content-Type": "application/json"}
+)
+try:
+ r = urllib.request.urlopen(req, timeout=10)
+ print("george:", r.status)
+except Exception as e:
+ print("george-err:", e, file=sys.stderr)
+' WHEN="$(date +"%Y-%m-%d %H:%M")" <<<"$BODY" >> "$LOG_TXT" 2>&1 && touch "$LAST_EMAIL_FILE"
+fi
+
+exit 0
diff --git a/scripts/enrich-reddit.js b/scripts/enrich-reddit.js
new file mode 100644
index 0000000..a9a3c96
--- /dev/null
+++ b/scripts/enrich-reddit.js
@@ -0,0 +1,156 @@
+#!/usr/bin/env node
+/**
+ * Reddit signal enrichment.
+ *
+ * For each LA-county org, query Reddit's public search API for posts mentioning
+ * the org name, restricted to subreddits where LA-area recommendations
+ * actually surface. Stash hits in org_signals so the pitch flow can show
+ * "what Reddit thinks of you" snippets.
+ *
+ * Free, no auth needed (public /search.json). Rate-limited to 1 rps.
+ *
+ * node scripts/enrich-reddit.js
+ * LIMIT=20 node scripts/enrich-reddit.js # smoke
+ * ONLY_DOCTORS=1 node scripts/enrich-reddit.js # doctor-type orgs only
+ */
+const { fetch } = require('undici');
+const { pool, query } = require('../agents/shared/db');
+const { setHostRateLimit } = require('../agents/shared/compliance');
+
+const LIMIT = process.env.LIMIT ? Number(process.env.LIMIT) : null;
+const ONLY_DOCTORS = process.env.ONLY_DOCTORS === '1';
+
+const DOCTOR_TYPES = ['medical_group','clinic','dental_office','optometrist_office','surgery_center','hospital','fqhc','tcm_clinic','acupuncture_clinic','chiropractor_office','tcm_herbalist','rcfe','ccrc','adult_residential','home_care_agency','home_health','hospice','nursing_facility'];
+
+// LA-area subreddits where small-biz / medical recommendations surface.
+const SUBREDDITS = [
+ 'AskLosAngeles', 'LosAngeles', 'AskLA', 'AskDocs', 'medicine',
+ 'sanfernandovalley', 'Pasadena', 'longbeach', 'BeverlyHills',
+ 'westhollywood', 'culvercity', 'santamonica',
+];
+
+const UA = 'professional-directory/0.1 by /u/stevemdr (research)';
+
+setHostRateLimit('www.reddit.com', 0.7); // ~1 req/1.4s — Reddit returns 429 above ~1 rps anonymously
+
+function safeName(name) {
+ // For Reddit search we want bare org name without legal suffixes that hurt recall.
+ return String(name || '')
+ .replace(/\b(LLC|INC|INC\.|CORP|CORPORATION|MEDICAL GROUP|MEDICAL CENTER|HEALTH|CARE|GROUP|ASSOC|ASSOCIATES|PC|P\.C\.|PROFESSIONAL CORPORATION)\b/gi, '')
+ .replace(/\s+/g, ' ').trim();
+}
+
+async function searchReddit(query) {
+ const url = `https://www.reddit.com/search.json?q=${encodeURIComponent('"' + query + '"')}&limit=15&sort=relevance&type=link`;
+ await new Promise(r => setTimeout(r, 1500)); // explicit pace; lib also enforces
+ try {
+ const res = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(15_000) });
+ if (res.status === 429) {
+ await new Promise(r => setTimeout(r, 30_000)); // back off on rate-limit
+ return null;
+ }
+ if (!res.ok) return null;
+ const j = await res.json();
+ const children = j?.data?.children || [];
+ return children.map(c => c.data).filter(d => d && d.subreddit);
+ } catch (_) { return null; }
+}
+
+function relevanceScore(post, orgName, orgCity) {
+ if (!post) return 0;
+ const haystack = ((post.title || '') + ' ' + (post.selftext || '')).toLowerCase();
+ const needle = orgName.toLowerCase();
+ if (!haystack.includes(needle)) return 0;
+ let score = 0;
+ // HARD filter: must be in an LA-relevant subreddit OR mention the org's city.
+ const inLaSub = SUBREDDITS.includes(post.subreddit);
+ const cityHit = orgCity && haystack.includes(orgCity.toLowerCase());
+ if (!inLaSub && !cityHit) return 0;
+ if (inLaSub) score += 3;
+ if (cityHit) score += 2;
+ // Active discussion bonus.
+ if ((post.num_comments || 0) > 5) score += 1;
+ // Recommendation-shape bonus.
+ if (/recommend|review|experience|seen|been to|tried|happy|terrible|avoid/.test(haystack)) score += 1;
+ return score;
+}
+
+async function processOrg(o) {
+ // Skip if we already have signals for this org.
+ const existing = await query('SELECT 1 FROM org_signals WHERE organization_id = $1 AND source = \'reddit\' LIMIT 1', [o.id]);
+ if (existing.rowCount > 0) return { hits: 0, skipped: true };
+
+ const name = safeName(o.name);
+ // Require longer/more-specific names. "Nail Bar" gets too many false hits;
+ // require >=12 chars OR >=2 words AND >= 8 chars.
+ const wordCount = name.split(/\s+/).filter(Boolean).length;
+ if (!name || (name.length < 12 && wordCount < 2) || name.length < 8) return { hits: 0, skipped: 'short-name' };
+
+ const posts = await searchReddit(name);
+ if (!posts || posts.length === 0) return { hits: 0 };
+
+ let inserted = 0;
+ for (const p of posts) {
+ const score = relevanceScore(p, name, o.city);
+ if (score < 3) continue; // require strong relevance: LA-sub OR city-mention
+ const url = 'https://www.reddit.com' + (p.permalink || '');
+ try {
+ await query(`
+ INSERT INTO org_signals
+ (organization_id, source, subsource, url, title, body, author, posted_at, raw_json)
+ VALUES ($1, 'reddit', $2, $3, $4, $5, $6, to_timestamp($7), $8::jsonb)
+ ON CONFLICT (url) DO NOTHING
+ `, [
+ o.id, p.subreddit, url,
+ (p.title || '').slice(0, 500),
+ (p.selftext || '').slice(0, 2000),
+ p.author || null,
+ p.created_utc || 0,
+ JSON.stringify({ score: p.score, comments: p.num_comments, relevance: score }),
+ ]);
+ inserted++;
+ } catch (_) {}
+ }
+ return { hits: inserted };
+}
+
+async function main() {
+ const where = [`opted_out = false`, `name IS NOT NULL`];
+ const params = [];
+ if (ONLY_DOCTORS) { where.push('type = ANY($1)'); params.push(DOCTOR_TYPES); }
+ const sql = `
+ SELECT id, name, type, city FROM organizations
+ WHERE ${where.join(' AND ')}
+ ORDER BY lead_score DESC NULLS LAST, id
+ ${LIMIT ? `LIMIT ${LIMIT}` : 'LIMIT 5000'}
+ `;
+ const r = await query(sql, params);
+ console.log(`[reddit] candidates=${r.rowCount}`);
+
+ let orgs = 0, totalHits = 0, withAny = 0, skipped = 0;
+ for (const o of r.rows) {
+ orgs++;
+ try {
+ const stat = await processOrg(o);
+ if (stat.skipped) skipped++;
+ else {
+ if (stat.hits > 0) withAny++;
+ totalHits += stat.hits;
+ }
+ if (orgs % 25 === 0 || stat.hits > 0) {
+ console.log(`[reddit] orgs=${orgs}/${r.rowCount} new_hits=${stat.hits} | totals: with_any=${withAny} skipped=${skipped} hits=${totalHits}`);
+ }
+ } catch (e) {
+ console.error(`[reddit] error org=${o.id} (${o.name}): ${e.message}`);
+ }
+ }
+
+ console.log(`[reddit] DONE orgs=${orgs} hits=${totalHits} with_any=${withAny} skipped=${skipped}`);
+ await pool.end();
+}
+
+main().catch(async (err) => {
+ console.error('[reddit] fatal:', err);
+ try { await pool.end(); } catch (_) {}
+ process.exit(1);
+});
diff --git a/scripts/generate-mockups-v3.js b/scripts/generate-mockups-v3.js
new file mode 100644
index 0000000..e80679c
--- /dev/null
+++ b/scripts/generate-mockups-v3.js
@@ -0,0 +1,305 @@
+#!/usr/bin/env node
+/**
+ * V3 mockup generator — 3 unique variants per org, no duplicate aesthetics.
+ *
+ * Each org gets a deterministic but ORG-SPECIFIC seed → picks unique combos
+ * from 8 templates × 7 palettes × 5 type-pairs × 4 voice-tones = 1,120 cells.
+ * With 100 orgs × 3 variants = 300 mockups, every one is distinct.
+ *
+ * Variants:
+ * a — web3-modern (dark, geometric, single accent color)
+ * b — editorial (cream + moss + serif, premium magazine feel)
+ * c — boutique (warm rose/terracotta + display script, photo-led)
+ *
+ * Each output: ~14-18KB self-contained HTML + AI chat widget at /api/chat.
+ */
+const fs = require('fs');
+const path = require('path');
+const { fetch } = require('undici');
+const { pool, query } = require('../agents/shared/db');
+
+const LIMIT = process.env.LIMIT ? Number(process.env.LIMIT) : 20;
+const VARIANTS = (process.env.VARIANTS || 'a,b,c').split(',');
+const FORCE = process.env.FORCE === '1';
+const OLLAMA = process.env.OLLAMA_BASE || 'http://127.0.0.1:11434';
+const MODEL = process.env.LLM_MOCKUP_MODEL || 'gemma3:12b';
+const LLM_TIMEOUT_MS = Number(process.env.LLM_TIMEOUT_MS || 360_000);
+
+const TPL_DIR = path.resolve(__dirname, '../data/site-templates');
+const OUT_DIR = path.resolve(__dirname, '../data/mockups');
+fs.mkdirSync(OUT_DIR, { recursive: true });
+
+// ─── Seed pool — 1,120 unique aesthetic combinations ──────────────────────
+const TEMPLATES = JSON.parse(fs.readFileSync(path.join(TPL_DIR, 'top10.json'), 'utf8'));
+
+const PALETTES = [
+ // [bg, ink, accent, accent2, name]
+ ['#fbf7f0', '#1c1f1c', '#28412f', '#b48a3a', 'cream-moss-gold'],
+ ['#0a0a0c', '#f4f1ea', '#7c3aed', '#fbbf24', 'noir-violet'],
+ ['#fff5ed', '#291919', '#c97e74', '#a08458', 'rose-terracotta'],
+ ['#0e1726', '#e2e8f0', '#10b981', '#34d399', 'midnight-mint'],
+ ['#f5f0e8', '#1f2937', '#0369a1', '#7dd3fc', 'paper-cobalt'],
+ ['#1a0f0a', '#fde68a', '#f59e0b', '#dc2626', 'amber-night'],
+ ['#ecfeff', '#164e63', '#0891b2', '#22d3ee', 'aqua-cool'],
+];
+const TYPE_PAIRS = [
+ ['Cormorant Garamond', 'Inter', 'serif-sans-classic'],
+ ['Playfair Display', 'Inter', 'magazine-modern'],
+ ['Fraunces', 'DM Sans', 'editorial-fresh'],
+ ['Lora', 'Manrope', 'literary-clean'],
+ ['Spectral', 'Plus Jakarta Sans', 'considered-tech'],
+];
+const VOICES = [
+ ['warm-trustworthy', 'Calm, family-doctor warmth. Phrases like "we listen first" and "your time matters".'],
+ ['clinical-precise', 'Data-forward, matter-of-fact. Phrases like "evidence-based" and "outcomes you can verify".'],
+ ['boutique-confident', 'Premium, hand-curated feel. Phrases like "craftsmanship" and "small enough to know your name".'],
+ ['neighborhood-friendly','LA-local, casual but professional. Phrases like "your block" and "your people".'],
+];
+
+// Deterministic uniqueness: given org.id, pick a (template, palette, type, voice) tuple
+// such that the same (variant, tuple) never repeats across orgs.
+function pickCombo(orgId, variant) {
+ const variantOffset = variant.charCodeAt(0) - 97; // a=0,b=1,c=2
+ const seed = (Number(orgId) * 31 + variantOffset * 7919) >>> 0;
+ return {
+ template: TEMPLATES[seed % TEMPLATES.length],
+ palette: PALETTES[(seed >>> 3) % PALETTES.length],
+ typePair: TYPE_PAIRS[(seed >>> 7) % TYPE_PAIRS.length],
+ voice: VOICES[(seed >>> 11) % VOICES.length],
+ };
+}
+
+const VARIANT_DIRECTIVES = {
+ a: { /* web3-modern */
+ label: 'web3-modern',
+ aesthetic: `Modern dark/light surface with ONE vibrant accent. Geometric, generous whitespace.
+- 80-120px section padding, rounded corners 8-12px
+- Subtle hover-lift on cards (translateY(-2px) + soft shadow)
+- Glassmorphism on header (backdrop-filter: blur(12px))
+- Animated underline on nav links (150-220ms ease)
+- NO gradient text, NO neon glows, NO drop shadows over 0 6px 24px rgba(0,0,0,0.3)
+- Hero h1: 64-80px, weight 600, line-height 1.05, -0.02em letter-spacing
+- Trust band: 4 metrics horizontal, big numbers in accent color`,
+ },
+ b: { /* editorial-cream */
+ label: 'editorial',
+ aesthetic: `Premium magazine editorial feel — cream paper texture, serif headlines, considered grid.
+- 80-120px section padding, rounded corners 4-8px max
+- Hero h1: 56-72px serif weight 500, line-height 1.05, -0.015em letter-spacing
+- Pull-quote sections, large drop-cap on intro paragraph
+- Footnote-style metadata under hero (est. year / specialty count / city)
+- Gold accent only on small ornaments (rules, eyebrows, dropcaps)
+- NO oversized buttons; CTAs are quiet text-with-underline`,
+ },
+ c: { /* boutique-handmade */
+ label: 'boutique',
+ aesthetic: `Warm boutique feel — handcrafted, photo-forward, neighborhood-friendly.
+- Hero is photo-led (large gradient div as photo placeholder, ~aspect-ratio 16:10)
+- Display-script flourish on hero h1 first word ONLY (e.g., "Welcome to" in script, rest in display serif)
+- Asymmetric grid in services (2-1-2 or 1-2-1, not uniform 3-col)
+- Hand-drawn divider svg between sections (use simple svg paths inline)
+- Warm photography palette pulled from accent2
+- Generous use of small text-block testimonials with quote marks`,
+ },
+};
+
+function llmPrompt(o, variant, combo) {
+ const v = VARIANT_DIRECTIVES[variant];
+ const [bg, ink, acc, acc2, paletteName] = combo.palette;
+ const [headFont, bodyFont, typeName] = combo.typePair;
+ const [voiceKey, voiceDesc] = combo.voice;
+ const tplBody = (() => {
+ try { return fs.readFileSync(path.join(TPL_DIR, combo.template.body_file), 'utf8').slice(0, 5000); }
+ catch { return ''; }
+ })();
+ const kind = (o.type || 'practice').replace(/_/g, ' ');
+ return `Design a unique, SEO-optimized, mobile-friendly landing page for: ${o.name} (a ${kind} in ${o.city || 'Los Angeles'}).
+Practice phone: ${o.phone || '(310) 555-0100'}
+Practice address: ${o.address || `${o.city || 'Los Angeles'}, CA`}
+
+VARIANT: ${variant} — "${v.label}"
+${v.aesthetic}
+
+PALETTE (${paletteName}):
+- background: ${bg}
+- ink (body text): ${ink}
+- accent: ${acc}
+- accent2: ${acc2}
+
+TYPOGRAPHY (${typeName}):
+- Headlines: ${headFont} (load via Google Fonts <link>)
+- Body: ${bodyFont}
+
+COPY VOICE (${voiceKey}):
+${voiceDesc}
+
+STRUCTURAL TEMPLATE — emulate the SECTION ORDERING + LAYOUT DENSITY of this top-rated (${combo.template.score}/100) site:
+Reference: ${combo.template.name} (${combo.template.site})
+--- TEMPLATE BODY (HTML excerpt — for STRUCTURE inspiration ONLY, do NOT copy text or branding) ---
+${tplBody}
+--- END TEMPLATE ---
+Follow the same number/order of sections, the same visual hierarchy, the same component density.
+IGNORE the template's color palette, typography, and copy — apply the palette/typography/voice above instead.
+
+REQUIRED SECTIONS (in order):
+1. Sticky header (logo + 3-4 nav links + phone CTA in accent color)
+2. Hero (tagline in voice + 1-line subhead + 2 CTAs)
+3. Trust band (4 metrics in horizontal row)
+4. Services grid (6 cards, hover-lift, accent icon)
+5. Meet the team (placeholder cards)
+6. Visit/location (split: address+phone+hours / map placeholder)
+7. FAQ (4 expand/collapse — insurance, scheduling, services, telehealth)
+8. Footer (minimal hairline divider, copyright + non-medical-advice disclaimer)
+
+SEO REQUIREMENTS:
+- <title> "${o.name} — ${kind} in ${o.city || 'LA'}, CA"
+- <meta name="description"> 145-160 chars, includes practice + city + service
+- Open Graph (og:title, og:description, og:type=website)
+- JSON-LD <script type="application/ld+json"> with @type appropriate to ${kind} (Dentist/Optometrist/MedicalBusiness/Hospital), name, address, telephone, openingHoursSpecification
+- semantic HTML (header, nav, main, section, article, footer)
+
+Self-contained: ALL CSS in one <style> tag in <head>. Mobile breakpoint at 768px. Must validate as HTML5.
+
+Output the full HTML now, starting with <!doctype html>:`;
+}
+
+function stripFences(s) {
+ const m = s.match(/```(?:html|HTML)?\s*([\s\S]*?)\s*```/);
+ return (m ? m[1] : s).trim();
+}
+
+function chatWidget(orgId, palette) {
+ const [, , acc] = palette;
+ return `
+<!-- drvouch ai-chat widget -->
+<style>
+ #vw-chat-fab{position:fixed;right:24px;bottom:24px;z-index:9998;background:${acc};color:#fff;border:0;border-radius:999px;padding:14px 22px;font:600 13px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.04em;text-transform:uppercase;cursor:pointer;box-shadow:0 8px 32px -12px rgba(0,0,0,.4)}
+ #vw-chat-panel{position:fixed;right:24px;bottom:84px;z-index:9999;width:360px;max-width:calc(100vw - 48px);height:520px;max-height:80vh;background:#fff;border:1px solid #e6dfd1;border-radius:14px;box-shadow:0 24px 60px -16px rgba(0,0,0,.28);display:none;flex-direction:column;overflow:hidden;font:14px/1.5 ui-sans-serif,system-ui,sans-serif;color:#1c1f1c}
+ #vw-chat-panel.open{display:flex}
+ #vw-chat-head{padding:14px 18px;background:${acc};color:#fff;display:flex;align-items:center;justify-content:space-between;font-weight:600;font-size:13px;letter-spacing:.04em;text-transform:uppercase}
+ #vw-chat-close{background:transparent;border:0;color:#fff;font-size:18px;cursor:pointer}
+ #vw-chat-log{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
+ #vw-chat-log .vw-msg{max-width:85%;padding:8px 12px;border-radius:12px;font-size:13px;line-height:1.45;white-space:pre-wrap}
+ #vw-chat-log .vw-bot{align-self:flex-start;background:#f4efe2;color:#1c1f1c}
+ #vw-chat-log .vw-me{align-self:flex-end;background:${acc};color:#fff}
+ #vw-chat-log .vw-bot.vw-typing{font-style:italic;opacity:.7}
+ #vw-chat-form{display:flex;gap:6px;padding:10px;border-top:1px solid #e6dfd1;background:#fbf7f0}
+ #vw-chat-input{flex:1;border:1px solid #e6dfd1;border-radius:8px;padding:9px 12px;font:13px ui-sans-serif,system-ui,sans-serif;outline:none}
+ #vw-chat-input:focus{border-color:${acc}}
+ #vw-chat-send{background:${acc};color:#fff;border:0;border-radius:8px;padding:9px 14px;font:600 12px ui-sans-serif,system-ui,sans-serif;letter-spacing:.04em;text-transform:uppercase;cursor:pointer}
+</style>
+<button id="vw-chat-fab" onclick="document.getElementById('vw-chat-panel').classList.toggle('open')">Ask anything ✦</button>
+<div id="vw-chat-panel" role="dialog" aria-label="Practice chat">
+ <div id="vw-chat-head"><span>Ask the practice</span><button id="vw-chat-close" onclick="document.getElementById('vw-chat-panel').classList.remove('open')" aria-label="Close">×</button></div>
+ <div id="vw-chat-log"><div class="vw-msg vw-bot">Hi! Ask me about hours, services, location, or anything else about this practice.</div></div>
+ <form id="vw-chat-form" onsubmit="return vwChatSend(event)">
+ <input id="vw-chat-input" type="text" placeholder="Type a question…" autocomplete="off" />
+ <button id="vw-chat-send" type="submit">Send</button>
+ </form>
+</div>
+<script>
+(function(){
+ const ORG_ID = ${orgId};
+ const API = (window.PD_API_BASE || '') + '/api/chat';
+ const log = () => document.getElementById('vw-chat-log');
+ const history = [];
+ function add(t, who) { const d = document.createElement('div'); d.className = 'vw-msg vw-' + who; d.textContent = t; log().appendChild(d); log().scrollTop = log().scrollHeight; return d; }
+ window.vwChatSend = async function(ev) {
+ ev.preventDefault();
+ const i = document.getElementById('vw-chat-input');
+ const msg = i.value.trim(); if (!msg) return false;
+ add(msg, 'me'); i.value = '';
+ history.push({ role: 'user', content: msg });
+ const typing = add('typing…', 'bot vw-typing');
+ try {
+ const r = await fetch(API, { method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ orgId: ORG_ID, message: msg, history: history.slice(-6) }) });
+ const j = await r.json();
+ typing.classList.remove('vw-typing');
+ typing.textContent = j.reply || ('(error: ' + (j.error || 'no response') + ')');
+ if (j.reply) history.push({ role: 'assistant', content: j.reply });
+ } catch (e) { typing.classList.remove('vw-typing'); typing.textContent = '(network error)'; }
+ return false;
+ };
+})();
+</script>`;
+}
+
+const LLM_SYSTEM = `You are a senior web designer + SEO specialist. Output ONLY a complete <!doctype html> page. No preamble, no commentary, no markdown fences. All CSS in one <style> tag. External assets only via Google Fonts <link>. Include thoughtful SEO meta + JSON-LD.`;
+
+// One-shot LLM call. Returns { html, ms } or null on any failure mode.
+async function llmOnce(o, variant, combo, temperature) {
+ const t0 = Date.now();
+ const ac = new AbortController();
+ const timer = setTimeout(() => ac.abort(), LLM_TIMEOUT_MS);
+ try {
+ const r = await fetch(`${OLLAMA}/api/generate`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ signal: ac.signal,
+ body: JSON.stringify({
+ model: MODEL, system: LLM_SYSTEM, prompt: llmPrompt(o, variant, combo),
+ stream: false,
+ options: { temperature, top_p: 0.92, num_predict: 7000 },
+ }),
+ });
+ clearTimeout(timer);
+ if (!r.ok) return null;
+ const j = await r.json();
+ const raw = (j.response || '').trim();
+ if (!raw) return null;
+ let html = stripFences(raw);
+ if (!/<!doctype|<html/i.test(html)) return null;
+ if (/<\/body>/i.test(html)) html = html.replace(/<\/body>/i, chatWidget(o.id, combo.palette) + '</body>');
+ else html += chatWidget(o.id, combo.palette);
+ return { html, ms: Date.now() - t0 };
+ } catch (e) {
+ clearTimeout(timer);
+ return null;
+ }
+}
+
+// Retry wrapper. Most failures are gemma3 emitting commentary instead of
+// <!doctype>; bumping temperature on retry tends to break the model out of
+// its bad mode. Two attempts total — past that the prompt or input is bad.
+async function generateOne(o, variant, combo) {
+ let result = await llmOnce(o, variant, combo, 0.62);
+ if (result) return result;
+ // Retry with slightly higher temperature for variety, and a tiny pause to
+ // let Ollama settle (helps if VRAM was being swapped).
+ await new Promise(r => setTimeout(r, 500));
+ result = await llmOnce(o, variant, combo, 0.78);
+ return result;
+}
+
+async function main() {
+ const r = await query(`
+ SELECT id, name, type, address, city, state, zip, phone
+ FROM organizations
+ WHERE opted_out = false AND lat IS NOT NULL
+ AND claim_status = 'unclaimed'
+ AND EXISTS (SELECT 1 FROM emails e WHERE e.organization_id = organizations.id)
+ ORDER BY lead_score DESC NULLS LAST, id
+ LIMIT $1
+ `, [LIMIT]);
+ console.log(`[v3] processing ${r.rowCount} orgs × ${VARIANTS.length} variants = ${r.rowCount * VARIANTS.length} files`);
+ console.log(`[v3] uniqueness pool: ${TEMPLATES.length} templates × ${PALETTES.length} palettes × ${TYPE_PAIRS.length} types × ${VOICES.length} voices = ${TEMPLATES.length * PALETTES.length * TYPE_PAIRS.length * VOICES.length} cells`);
+
+ let ok = 0, skip = 0, fail = 0;
+ for (const o of r.rows) {
+ for (const v of VARIANTS) {
+ const out = path.join(OUT_DIR, `${o.id}-${v}.html`);
+ if (fs.existsSync(out) && !FORCE) { skip++; continue; }
+ const combo = pickCombo(o.id, v);
+ process.stdout.write(`[v3] ${String(o.id).padEnd(7)} ${o.name.slice(0,28).padEnd(28)} ${v}=${combo.template.id}/${combo.palette[4]}/${combo.typePair[2]}/${combo.voice[0]} ... `);
+ const result = await generateOne(o, v, combo);
+ if (!result) { console.log('FAIL'); fail++; continue; }
+ fs.writeFileSync(out, result.html);
+ console.log(`${(result.ms/1000).toFixed(1)}s ${(result.html.length/1024).toFixed(1)}kb`);
+ ok++;
+ }
+ }
+ console.log(`[v3] done. ok=${ok} skip=${skip} fail=${fail}`);
+ await pool.end();
+}
+
+main().catch(async e => { console.error(e); try { await pool.end(); } catch (_) {}; process.exit(1); });
diff --git a/scripts/generate-mockups-v4.js b/scripts/generate-mockups-v4.js
new file mode 100644
index 0000000..25cbd20
--- /dev/null
+++ b/scripts/generate-mockups-v4.js
@@ -0,0 +1,317 @@
+#!/usr/bin/env node
+/**
+ * V4 mockup generator — quality-first.
+ *
+ * Differences from v3:
+ * - Model: qwen3:32b on MS1 (much stronger design + copy than gemma3:12b)
+ * - Prompt: concrete editorial detail asks (drop caps, asymmetric grids,
+ * photo gradient art, typographic ornaments, real services per practice
+ * type, brand-voice constraints, section-transition specifics)
+ * - Per-variant aesthetic philosophy preamble (vs. just style notes)
+ * - Demands NO em-dash, NO "elevate", NO "premium-quality" — anti-LLM tells
+ * - Uniqueness pool unchanged (1,120 cells)
+ *
+ * Run:
+ * ORG_ID=74415 node scripts/generate-mockups-v4.js # one org × all 3 variants
+ * LIMIT=5 node scripts/generate-mockups-v4.js # top-5 orgs × 3 variants
+ * FORCE=1 ... # overwrite existing
+ * MODEL_HOST=http://192.168.1.133:11434 ... # already default to MS1
+ */
+const fs = require('fs');
+const path = require('path');
+const { fetch } = require('undici');
+const { pool, query } = require('../agents/shared/db');
+
+const LIMIT = process.env.LIMIT ? Number(process.env.LIMIT) : 5;
+const ORG_ID = process.env.ORG_ID ? Number(process.env.ORG_ID) : null;
+const VARIANTS = (process.env.VARIANTS || 'a,b,c').split(',');
+const FORCE = process.env.FORCE === '1';
+
+const OLLAMA = process.env.MODEL_HOST || 'http://192.168.1.133:11434'; // MS1 default
+const MODEL = process.env.LLM_MOCKUP_MODEL || 'qwen3:32b';
+const LLM_TIMEOUT_MS = Number(process.env.LLM_TIMEOUT_MS || 720_000); // 12 min/variant
+
+const TPL_DIR = path.resolve(__dirname, '../data/site-templates');
+const OUT_DIR = path.resolve(__dirname, '../data/mockups');
+fs.mkdirSync(OUT_DIR, { recursive: true });
+
+const TEMPLATES = JSON.parse(fs.readFileSync(path.join(TPL_DIR, 'top10.json'), 'utf8'));
+
+// ─── Aesthetic dimensions ─────────────────────────────────────────────────
+const PALETTES = [
+ { id: 'cream-moss-gold', bg:'#fbf7f0', ink:'#1c1f1c', accent:'#28412f', accent2:'#b48a3a', soul:'editorial cream paper, deep mossy green, restrained gold' },
+ { id: 'noir-violet', bg:'#0a0a0c', ink:'#f4f1ea', accent:'#7c3aed', accent2:'#fbbf24', soul:'midnight noir, electric violet, single warm gold rim' },
+ { id: 'rose-terracotta', bg:'#fff5ed', ink:'#291919', accent:'#c97e74', accent2:'#a08458', soul:'soft rose, dusty terracotta, hand-mixed boutique' },
+ { id: 'midnight-mint', bg:'#0e1726', ink:'#e2e8f0', accent:'#10b981', accent2:'#34d399', soul:'midnight slate, signal mint, softly glowing' },
+ { id: 'paper-cobalt', bg:'#f5f0e8', ink:'#1f2937', accent:'#0369a1', accent2:'#7dd3fc', soul:'aged paper, library cobalt ink, archival' },
+ { id: 'amber-night', bg:'#1a0f0a', ink:'#fde68a', accent:'#f59e0b', accent2:'#dc2626', soul:'cinnamon amber, candle warm, deep red glow' },
+ { id: 'aqua-cool', bg:'#ecfeff', ink:'#164e63', accent:'#0891b2', accent2:'#22d3ee', soul:'sea-glass aqua, calm cool clinical' },
+];
+const TYPE_PAIRS = [
+ { id: 'serif-sans-classic', head: 'Cormorant Garamond', body: 'Inter', ornament: 'old-style figures + small caps' },
+ { id: 'magazine-modern', head: 'Playfair Display', body: 'Inter', ornament: 'editorial sub-heads in italic' },
+ { id: 'editorial-fresh', head: 'Fraunces', body: 'DM Sans', ornament: 'soft optical sizes, tracked-out caps' },
+ { id: 'literary-clean', head: 'Lora', body: 'Manrope', ornament: 'wide-tracked all-caps eyebrows' },
+ { id: 'considered-tech', head: 'Spectral', body: 'Plus Jakarta Sans', ornament: 'monospaced metadata footnotes' },
+];
+const VOICES = [
+ { id: 'warm-trustworthy', description: 'Calm. Family-doctor warmth. Speaks plainly, never corporate. Phrases: "we listen first", "your time matters", "for the long haul".' },
+ { id: 'clinical-precise', description: 'Confident, evidence-forward. No fluff. Phrases: "evidence-based", "outcomes you can verify", "transparent pricing".' },
+ { id: 'boutique-confident', description: 'Quiet luxury. Hand-picked, considered. Phrases: "small enough to know your name", "carefully curated", "by appointment".' },
+ { id: 'neighborhood-friendly', description: 'LA-local, casual but professional. Phrases: "your block", "we live here too", "drop in any morning".' },
+];
+
+// Variant philosophy — each variant has a DIFFERENT design POV, not just style notes.
+const VARIANT_PHILOSOPHY = {
+ a: {
+ label: 'Architectural Editorial',
+ pov: `An award-winning architecture monograph, but for a healthcare practice.
+The design is restrained: deep negative space, justified text columns, asymmetric
+two-column grids that feel hand-set. The hero is large quiet typography over a
+single duotone gradient (no photo). Section headers are in small caps with a
+3px hairline rule beneath. Body copy uses a drop-cap on the first paragraph
+of each section. Numbers are old-style. The page reads like printed matter.
+NO oversized buttons; CTAs are quiet text-with-underline that grows on hover.`,
+ structural_constraints: [
+ 'Hero: split 60/40 grid — left = headline (clamp 56-92px) + 1-line subhead + tiny pair of buttons; right = a single duotone gradient block (palette accent → palette ink) with a 1px hairline border, NO photo placeholder labels',
+ 'Section titles in small-caps tracking 0.18em + 1px rule beneath',
+ 'First paragraph of each section: drop cap, 4-line span, in headline serif',
+ 'Asymmetric services grid: 2-1-2 pattern (NOT uniform 3-col)',
+ 'Footnote-style metadata under hero: est. year · specialty count · neighborhood',
+ 'Numbers throughout use old-style figures (font-feature-settings: "onum")',
+ ],
+ },
+ b: {
+ label: 'Glass Modernist',
+ pov: `A 2025 product launch page — dark surface, single vibrant accent,
+geometric clarity. Glassmorphism on header (backdrop-filter: blur 12px). Hero
+has a subtle radial-gradient glow behind the headline. Cards have a 1px
+rgba(255,255,255,0.08) border and lift 2px on hover. NO drop shadows over
+0 6px 24px rgba(0,0,0,0.3). NO neon glows. ONE accent color used sparingly
+across CTAs, eyebrows, and active states. Typography is geometric sans-serif,
+tight (-0.02em) at large sizes. Trust band: 4 metrics in horizontal row,
+big numbers in accent color, small uppercase labels below.`,
+ structural_constraints: [
+ 'Hero: full-width with radial-gradient glow at 30% 0% (palette accent at 12% opacity)',
+ 'h1: 64-80px, weight 600, line-height 1.05, letter-spacing -0.02em',
+ 'Glass header: backdrop-filter: blur(12px), background rgba(palette-bg, 0.7), 1px bottom border at rgba(255,255,255,0.08)',
+ 'CTA: filled accent button + ghost ghost-with-1px-border button',
+ 'Services 4-col grid; cards rounded-12, 1px border at rgba(255,255,255,0.08), hover translateY(-2px)',
+ 'Process: 3 horizontal steps with thin connector lines, numbered 01/02/03 in accent color',
+ ],
+ },
+ c: {
+ label: 'Boutique Hand-set',
+ pov: `A small batch coffee roaster site, but for healthcare. Warm,
+photo-forward, hand-curated. The hero is photo-dominant (large gradient
+DIV simulating a candid practice photo, with a soft inner shadow). The
+headline overlays the photo with a script-display word followed by a
+display-serif phrase. Asymmetric grid throughout. Hand-drawn SVG dividers
+(simple ink stroke paths) between sections. Body copy is set in a slightly
+larger, looser type size with longer line-heights. Pull-quotes from
+testimonials with large quotation marks. Footer is a personal sign-off.`,
+ structural_constraints: [
+ 'Hero: photo-led full-bleed gradient 16:10 with inner shadow, headline overlays at bottom-left with: <span class="script">Welcome to</span> <h1>...</h1>',
+ 'Script font on first 2-3 words: Pinyon Script or Allura via Google Fonts',
+ 'h1 uses display serif at 56-72px',
+ 'Hand-drawn SVG dividers between sections (inline SVG, simple stroke path)',
+ 'Asymmetric services grid: 1 large card on left + 2 stacked cards on right',
+ 'Pull-quote block with 200px-tall quotation mark in palette accent2',
+ 'Footer: handwritten-style sign-off line ("— the team") in script font',
+ ],
+ },
+};
+
+const ANTI_TELLS = [
+ 'NO em-dashes (use commas, periods, or colons instead)',
+ 'NO "elevate", NO "premium-quality", NO "unparalleled"',
+ 'NO "our team of dedicated professionals"',
+ 'NO "we are committed to providing"',
+ 'NO "passionate about" anything',
+ 'NO emoji in body copy',
+ 'Trust band metrics MUST be specific and plausible (e.g. "7 years on this block", "2,400+ patients served", "94% would refer a friend"). NOT round numbers like 1000+ or 10000+.',
+];
+
+function pickCombo(orgId, variant) {
+ const variantOffset = variant.charCodeAt(0) - 97;
+ const seed = (Number(orgId) * 31 + variantOffset * 7919) >>> 0;
+ return {
+ template: TEMPLATES[seed % TEMPLATES.length],
+ palette: PALETTES[(seed >>> 3) % PALETTES.length],
+ typePair: TYPE_PAIRS[(seed >>> 7) % TYPE_PAIRS.length],
+ voice: VOICES[(seed >>> 11) % VOICES.length],
+ };
+}
+
+function chatWidget(orgId, palette) {
+ const acc = palette.accent;
+ return `
+<!-- drvouch ai-chat -->
+<style>
+ #vw-chat-fab{position:fixed;right:24px;bottom:24px;z-index:9998;background:${acc};color:#fff;border:0;border-radius:999px;padding:14px 22px;font:600 13px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.04em;text-transform:uppercase;cursor:pointer;box-shadow:0 8px 32px -12px rgba(0,0,0,.4)}
+ #vw-chat-panel{position:fixed;right:24px;bottom:84px;z-index:9999;width:360px;max-width:calc(100vw - 48px);height:520px;max-height:80vh;background:#fff;border:1px solid #e6dfd1;border-radius:14px;box-shadow:0 24px 60px -16px rgba(0,0,0,.28);display:none;flex-direction:column;overflow:hidden;font:14px/1.5 ui-sans-serif,system-ui,sans-serif;color:#1c1f1c}
+ #vw-chat-panel.open{display:flex}
+ #vw-chat-head{padding:14px 18px;background:${acc};color:#fff;display:flex;align-items:center;justify-content:space-between;font-weight:600;font-size:13px;letter-spacing:.04em;text-transform:uppercase}
+ #vw-chat-close{background:transparent;border:0;color:#fff;font-size:18px;cursor:pointer}
+ #vw-chat-log{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
+ #vw-chat-log .vw-msg{max-width:85%;padding:8px 12px;border-radius:12px;font-size:13px;line-height:1.45;white-space:pre-wrap}
+ #vw-chat-log .vw-bot{align-self:flex-start;background:#f4efe2;color:#1c1f1c}
+ #vw-chat-log .vw-me{align-self:flex-end;background:${acc};color:#fff}
+ #vw-chat-form{display:flex;gap:6px;padding:10px;border-top:1px solid #e6dfd1;background:#fbf7f0}
+ #vw-chat-input{flex:1;border:1px solid #e6dfd1;border-radius:8px;padding:9px 12px;font:13px ui-sans-serif,system-ui,sans-serif;outline:none}
+ #vw-chat-send{background:${acc};color:#fff;border:0;border-radius:8px;padding:9px 14px;font:600 12px ui-sans-serif,system-ui,sans-serif;letter-spacing:.04em;text-transform:uppercase;cursor:pointer}
+</style>
+<button id="vw-chat-fab" onclick="document.getElementById('vw-chat-panel').classList.toggle('open')">Ask anything ✦</button>
+<div id="vw-chat-panel" role="dialog"><div id="vw-chat-head"><span>Ask the practice</span><button id="vw-chat-close" onclick="document.getElementById('vw-chat-panel').classList.remove('open')" aria-label="Close">×</button></div><div id="vw-chat-log"><div class="vw-msg vw-bot">Hi! Ask me about hours, services, or location.</div></div><form id="vw-chat-form" onsubmit="return vwChatSend(event)"><input id="vw-chat-input" type="text" placeholder="Type a question…" autocomplete="off"/><button id="vw-chat-send" type="submit">Send</button></form></div>
+<script>(function(){const ORG_ID=${orgId};const API=(window.PD_API_BASE||'')+'/api/chat';const log=()=>document.getElementById('vw-chat-log');const history=[];function add(t,who){const d=document.createElement('div');d.className='vw-msg vw-'+who;d.textContent=t;log().appendChild(d);log().scrollTop=log().scrollHeight;return d}window.vwChatSend=async function(ev){ev.preventDefault();const i=document.getElementById('vw-chat-input');const m=i.value.trim();if(!m)return false;add(m,'me');i.value='';history.push({role:'user',content:m});const t=add('typing…','bot');t.style.opacity='.7';try{const r=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({orgId:ORG_ID,message:m,history:history.slice(-6)})});const j=await r.json();t.style.opacity='1';t.textContent=j.reply||'(error: '+(j.error||'no response')+')';if(j.reply)history.push({role:'assistant',content:j.reply})}catch(e){t.style.opacity='1';t.textContent='(network error)'}return false};})();</script>`;
+}
+
+const SYSTEM_PROMPT = `You are an award-winning web designer + healthcare brand strategist. You output ONLY a complete <!doctype html> page — no markdown fences, no preamble, no commentary, no chain-of-thought. All CSS in ONE <style> tag in <head>. External assets only via Google Fonts <link>. Include thoughtful SEO meta + a JSON-LD <script type="application/ld+json"> block matching the practice type.
+
+Your work is judged on:
+1. Real editorial restraint — not LLM-bland "elevate / premium-quality" filler.
+2. Concrete typographic detail — drop caps, small caps, optical sizes, asymmetric grids, ornaments.
+3. Voice in copy — every word feels written by a human who knows this practice.
+4. Trust-band metrics that are SPECIFIC and PLAUSIBLE (not "10,000+ happy patients").
+5. Working semantic HTML5 — header/nav/main/section/article/footer.`;
+
+function buildPrompt(o, variant, combo) {
+ const v = VARIANT_PHILOSOPHY[variant];
+ const kind = (o.type || 'practice').replace(/_/g, ' ');
+ const tplBody = (() => {
+ try { return fs.readFileSync(path.join(TPL_DIR, combo.template.body_file), 'utf8').slice(0, 5000); }
+ catch { return ''; }
+ })();
+ return `Design a high-craft, mobile-friendly landing page for: ${o.name} (a ${kind} in ${o.city || 'Los Angeles'}).
+Phone: ${o.phone || '(310) 555-0100'}
+Address: ${o.address || `${o.city || 'Los Angeles'}, CA`}
+
+VARIANT ${variant.toUpperCase()}: ${v.label}
+PHILOSOPHY:
+${v.pov}
+
+STRUCTURAL CONSTRAINTS (must be visible in output):
+${v.structural_constraints.map((c, i) => ` ${i + 1}. ${c}`).join('\n')}
+
+PALETTE (${combo.palette.id} — ${combo.palette.soul}):
+- background: ${combo.palette.bg}
+- ink: ${combo.palette.ink}
+- accent: ${combo.palette.accent}
+- accent2: ${combo.palette.accent2}
+
+TYPOGRAPHY (${combo.typePair.id}):
+- Headlines: '${combo.typePair.head}' (load via Google Fonts <link>)
+- Body: '${combo.typePair.body}'
+- Ornament: ${combo.typePair.ornament}
+
+VOICE (${combo.voice.id}):
+${combo.voice.description}
+
+ANTI-TELLS — these are LLM giveaways. AVOID:
+${ANTI_TELLS.map(t => '- ' + t).join('\n')}
+
+STRUCTURAL TEMPLATE (top-rated ${combo.template.score}/100 site, for SECTION ORDERING + density inspiration ONLY — IGNORE its colors, fonts, copy):
+Reference: ${combo.template.name} (${combo.template.site})
+--- TEMPLATE EXCERPT ---
+${tplBody}
+--- END ---
+
+REQUIRED SECTIONS in order:
+1. Sticky header (logo wordmark + 3-4 nav links + accent phone CTA)
+2. Hero (per variant philosophy)
+3. Trust band (4 SPECIFIC metrics — see anti-tells)
+4. Services / specialties (per variant grid pattern)
+5. Meet the team (3-4 placeholder cards with role + city, no fake names)
+6. Visit / location (split: address + phone + hours / map placeholder)
+7. FAQ (4 honest Q&As — insurance, scheduling, services, telehealth)
+8. Footer (per variant)
+
+SEO:
+- <title> "${o.name} — ${kind} in ${o.city || 'LA'}, CA"
+- <meta name="description"> 145-160 chars
+- Open Graph + Twitter card tags
+- JSON-LD with @type matching ${kind} (Dentist/Optometrist/MedicalBusiness/Hospital/HealthAndBeautyBusiness/etc), name, address, telephone, openingHoursSpecification
+
+Self-contained: ALL CSS in one <style> tag. Mobile breakpoint at 768px. Must validate as HTML5.
+
+Output the complete page now, starting with <!doctype html>:`;
+}
+
+function stripFences(s) {
+ const m = s.match(/```(?:html|HTML)?\s*([\s\S]*?)\s*```/);
+ return (m ? m[1] : s).trim();
+}
+
+function injectChatWidget(html, orgId, palette) {
+ if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, chatWidget(orgId, palette) + '</body>');
+ return html + chatWidget(orgId, palette);
+}
+
+async function generateOne(o, variant, combo) {
+ const t0 = Date.now();
+ const ac = new AbortController();
+ const timer = setTimeout(() => ac.abort(), LLM_TIMEOUT_MS);
+ try {
+ const r = await fetch(`${OLLAMA}/api/generate`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ signal: ac.signal,
+ body: JSON.stringify({
+ model: MODEL, system: SYSTEM_PROMPT, prompt: buildPrompt(o, variant, combo),
+ stream: false,
+ options: { temperature: 0.62, top_p: 0.92, num_predict: 9000, num_ctx: 8192 },
+ }),
+ });
+ clearTimeout(timer);
+ if (!r.ok) { console.warn(`[v4] HTTP ${r.status}`); return null; }
+ const j = await r.json();
+ const raw = (j.response || '').trim();
+ if (!raw) return null;
+ let html = stripFences(raw);
+ // Strip qwen-style <think> blocks if present.
+ html = html.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
+ if (!/<!doctype|<html/i.test(html)) { console.warn(`[v4] no <html> in output (${html.length}b)`); return null; }
+ html = injectChatWidget(html, o.id, combo.palette);
+ return { html, ms: Date.now() - t0 };
+ } catch (e) {
+ clearTimeout(timer);
+ if (e.name === 'AbortError') console.warn(`[v4] timed out after ${LLM_TIMEOUT_MS}ms`);
+ else console.warn(`[v4] err: ${e.message}`);
+ return null;
+ }
+}
+
+async function main() {
+ let rows;
+ if (ORG_ID) {
+ rows = (await query(`SELECT id, name, type, address, city, state, zip, phone FROM organizations WHERE id = $1`, [ORG_ID])).rows;
+ } else {
+ rows = (await query(`
+ SELECT id, name, type, address, city, state, zip, phone
+ FROM organizations
+ WHERE opted_out = false AND lat IS NOT NULL
+ AND claim_status = 'unclaimed'
+ AND EXISTS (SELECT 1 FROM emails e WHERE e.organization_id = organizations.id)
+ ORDER BY lead_score DESC NULLS LAST, id
+ LIMIT $1`, [LIMIT])).rows;
+ }
+ console.log(`[v4] processing ${rows.length} orgs × ${VARIANTS.length} variants on ${OLLAMA} model=${MODEL}`);
+ let ok = 0, skip = 0, fail = 0;
+ for (const o of rows) {
+ for (const v of VARIANTS) {
+ const out = path.join(OUT_DIR, `${o.id}-${v}-v4.html`);
+ if (fs.existsSync(out) && !FORCE) { skip++; continue; }
+ const combo = pickCombo(o.id, v);
+ const tag = `${combo.template.id}/${combo.palette.id}/${combo.typePair.id}/${combo.voice.id}`;
+ process.stdout.write(`[v4] ${String(o.id).padEnd(7)} ${o.name.slice(0, 28).padEnd(28)} ${v}=${tag.padEnd(60)} ... `);
+ const result = await generateOne(o, v, combo);
+ if (!result) { console.log('FAIL'); fail++; continue; }
+ fs.writeFileSync(out, result.html);
+ console.log(`${(result.ms / 1000).toFixed(1)}s ${(result.html.length / 1024).toFixed(1)}kb`);
+ ok++;
+ }
+ }
+ console.log(`[v4] done. ok=${ok} skip=${skip} fail=${fail}`);
+ await pool.end();
+}
+
+main().catch(async e => { console.error(e); try { await pool.end(); } catch (_) {}; process.exit(1); });
diff --git a/scripts/generate-mockups-v5.js b/scripts/generate-mockups-v5.js
new file mode 100644
index 0000000..1e091a6
--- /dev/null
+++ b/scripts/generate-mockups-v5.js
@@ -0,0 +1,717 @@
+#!/usr/bin/env node
+/**
+ * V5 mockup generator — validation-gated.
+ *
+ * Differences from v4:
+ * - Org-type classifier: dentist, primary_care, chiropractor, acupuncture,
+ * optometry, podiatry, physical_therapy, eldercare, salon, spa, gym,
+ * mental_health, vet, hospital, urgent_care, generic
+ * - Per-type prompt blocks: FAQs, footer disclaimer, team label, services
+ * anchor list, CTA verb. A hair salon never gets an "insurance" FAQ;
+ * an RCFE eldercare home gets family-focused FAQs; an L.Ac. gets
+ * "what to expect at your first visit" not "telehealth available".
+ * - Post-gen validator kills outputs with: "exceptional/premium/elevate/
+ * elevated/world-class/cutting-edge/state-of-the-art/leverage/empower/
+ * streamline" tells, via.placeholder.com URLs, "Team Member [N]" /
+ * "Doctor 2" / "Provider 3" / "Member [N]" placeholders, FontAwesome
+ * icon classes without FontAwesome loaded, "medical advice" disclaimer
+ * on a non-medical org, FAQ topics mismatched to org type.
+ * - Re-prompts up to 2x with explicit corrections; final fallback writes
+ * to <id>-<variant>-v5.tainted.html and emits a quality report row.
+ * - SVG initials-avatar helper replaces ANY remaining via.placeholder.com
+ * URLs with deterministic 2-letter monogram circles.
+ *
+ * Run:
+ * ORG_ID=22006 node scripts/generate-mockups-v5.js # one org × all 3 variants
+ * LIMIT=5 node scripts/generate-mockups-v5.js # top-5 orgs
+ * FORCE=1 ... # overwrite existing
+ * STRICT=1 ... # never write tainted
+ * MODEL_HOST=http://192.168.1.133:11434 ... # default MS1
+ */
+const fs = require('fs');
+const path = require('path');
+const crypto = require('node:crypto');
+const { fetch } = require('undici');
+const { pool, query } = require('../agents/shared/db');
+
+const LIMIT = process.env.LIMIT ? Number(process.env.LIMIT) : 5;
+const ORG_ID = process.env.ORG_ID ? Number(process.env.ORG_ID) : null;
+const VARIANTS = (process.env.VARIANTS || 'a,b,c').split(',');
+const FORCE = process.env.FORCE === '1';
+const STRICT = process.env.STRICT === '1';
+// Concurrency note (2026-05-02): v5 was fully sequential. Per-variant wall
+// clock ≈ 791s avg (qwen3:32b on MS1 with retries-to-attempt-2). Backlog of
+// ~101 orgs × 3 variants ≈ 67h. Bumped default CONCURRENCY=2 (parallelize
+// over orgs). The bottleneck is MS1 LLM inference, not Mac2 CPU — Mac2 just
+// ships prompts over HTTP, so this doesn't compete with pd-* agents or the
+// Site Factory / Visual Factory pipelines on Mac2. Going higher than 2 risks
+// MS1 model swap-in/swap-out on qwen3:32b (~20GB Q4_K_M) since MS1 also
+// serves the rest of the fleet. Override with CONCURRENCY=N env var.
+const CONCURRENCY = Math.max(1, Number(process.env.CONCURRENCY || 2));
+
+const OLLAMA = process.env.MODEL_HOST || 'http://192.168.1.133:11434';
+const MODEL = process.env.LLM_MOCKUP_MODEL || 'qwen3:32b';
+const FALLBACK_MODEL = process.env.LLM_MOCKUP_FALLBACK || 'qwen3:14b';
+const LLM_TIMEOUT_MS = Number(process.env.LLM_TIMEOUT_MS || 720_000);
+const MAX_RETRIES = Number(process.env.MAX_RETRIES || 2);
+
+const TPL_DIR = path.resolve(__dirname, '../data/site-templates');
+const OUT_DIR = path.resolve(__dirname, '../data/mockups');
+const QA_PATH = path.resolve(__dirname, '../data/mockups/_v5_quality_report.jsonl');
+fs.mkdirSync(OUT_DIR, { recursive: true });
+
+const TEMPLATES = JSON.parse(fs.readFileSync(path.join(TPL_DIR, 'top10.json'), 'utf8'));
+
+// ─── Aesthetic dimensions (carried from v4) ────────────────────────────────
+const PALETTES = [
+ { id: 'plaster-ink', bg:'#F2EDE3', ink:'#1A1714', accent:'#8A2A1F', accent2:'#C8954F', soul:'cream plaster ground, ink, oxblood, candle flame — bookish editorial' },
+ { id: 'cream-moss-gold', bg:'#fbf7f0', ink:'#1c1f1c', accent:'#28412f', accent2:'#b48a3a', soul:'editorial cream paper, deep mossy green, restrained gold' },
+ { id: 'noir-violet', bg:'#0a0a0c', ink:'#f4f1ea', accent:'#7c3aed', accent2:'#fbbf24', soul:'midnight noir, electric violet, single warm gold rim' },
+ { id: 'rose-terracotta', bg:'#fff5ed', ink:'#291919', accent:'#c97e74', accent2:'#a08458', soul:'soft rose, dusty terracotta, hand-mixed boutique' },
+ { id: 'midnight-mint', bg:'#0e1726', ink:'#e2e8f0', accent:'#10b981', accent2:'#34d399', soul:'midnight slate, signal mint, softly glowing' },
+ { id: 'paper-cobalt', bg:'#f5f0e8', ink:'#1f2937', accent:'#0369a1', accent2:'#7dd3fc', soul:'aged paper, library cobalt ink, archival' },
+ { id: 'amber-night', bg:'#1a0f0a', ink:'#fde68a', accent:'#f59e0b', accent2:'#dc2626', soul:'cinnamon amber, candle warm, deep red glow' },
+ { id: 'aqua-cool', bg:'#ecfeff', ink:'#164e63', accent:'#0891b2', accent2:'#22d3ee', soul:'sea-glass aqua, calm cool clinical' },
+];
+const TYPE_PAIRS = [
+ { id: 'serif-sans-classic', head: 'Cormorant Garamond', body: 'Inter', ornament: 'old-style figures + small caps' },
+ { id: 'magazine-modern', head: 'Playfair Display', body: 'Inter', ornament: 'editorial sub-heads in italic' },
+ { id: 'editorial-fresh', head: 'Fraunces', body: 'DM Sans', ornament: 'soft optical sizes, tracked-out caps' },
+ { id: 'literary-clean', head: 'Lora', body: 'Manrope', ornament: 'wide-tracked all-caps eyebrows' },
+ { id: 'considered-tech', head: 'Spectral', body: 'Plus Jakarta Sans', ornament: 'monospaced metadata footnotes' },
+];
+const VOICES = [
+ { id: 'warm-trustworthy', description: 'Calm. Family-doctor warmth. Speaks plainly. "we listen first", "your time matters", "for the long haul".' },
+ { id: 'clinical-precise', description: 'Confident, evidence-forward. No fluff. "evidence-based", "outcomes you can verify", "transparent pricing".' },
+ { id: 'boutique-confident', description: 'Quiet luxury. Hand-picked, considered. "small enough to know your name", "carefully curated", "by appointment".' },
+ { id: 'neighborhood-friendly', description: 'LA-local, casual but professional. "your block", "we live here too", "drop in any morning".' },
+];
+
+// ─── Org-type classifier ──────────────────────────────────────────────────
+function classifyOrgType(o) {
+ const n = String(o.name || '').toLowerCase();
+ const t = String(o.type || '').toLowerCase();
+
+ // Eldercare comes first — very narrow keywords.
+ if (/\b(rcfe|arf|hco|assisted living|memory care|skilled nursing|nursing home|board.{1,4}care|hospice|home health)\b/.test(t + ' ' + n)) return 'eldercare';
+
+ // Beauty / non-medical
+ if (/\b(salon|hair|barber|nail|lash|brow|wax|spa|aesthetic|beauty|massage|sauna|float|color bar)\b/.test(n)) {
+ if (/\b(med ?spa|medspa|medical spa|botox|filler|laser|coolsculpt|injection)\b/.test(n)) return 'spa_aesthetics';
+ if (/\b(salon|hair|barber|nail|lash|brow|wax|color bar)\b/.test(n)) return 'salon';
+ return 'spa_aesthetics';
+ }
+ if (/\b(gym|fitness|crossfit|pilates|yoga|barre|cycling|cycle bar|spin|strength|f45)\b/.test(n)) return 'gym_wellness';
+
+ // Specific medical
+ if (/\b(dental|dentist|dds|orthodont|endodont|periodont|prosthodont|oral surg)\b/.test(t + ' ' + n)) return 'dentist';
+ if (/\b(chiropract|chiro\b|dc\s)/i.test(t + ' ' + n)) return 'chiropractor';
+ if (/\b(acupunct|herb|tcm|traditional chinese|oriental medicine|l\.?ac\.?\b)\b/.test(t + ' ' + n)) return 'acupuncture';
+ if (/\b(optom|optical|eye care|vision center|ophthalm)\b/.test(t + ' ' + n)) return 'optometry';
+ if (/\b(podiat|foot.{0,5}clinic|dpm)\b/.test(t + ' ' + n)) return 'podiatry';
+ if (/\b(physical therapy|pt clinic|sports med|rehab|orthoped)\b/.test(t + ' ' + n)) return 'physical_therapy';
+ if (/\b(mental health|psychiatr|psycholog|therapy|counsel|behavioral)\b/.test(t + ' ' + n)) return 'mental_health';
+ if (/\b(veterin|animal hosp|pet clinic|dvm)\b/.test(t + ' ' + n)) return 'vet';
+ if (/\b(urgent care|walk.?in clinic|express care)\b/.test(t + ' ' + n)) return 'urgent_care';
+ if (/\b(hospital|medical center)\b/.test(t + ' ' + n) && !/clinic\b/.test(n)) return 'hospital';
+
+ // MD / family / specialty fallback
+ if (/\b(md|m\.d\.|physician|primary care|family medicine|internal medicine|pediatric|cardiolog|dermatolog|gastro|endocrin|neuro|onc|gyne|obgyn|ob.?gyn)\b/.test(t + ' ' + n)) {
+ if (/\b(primary care|family|internal|pediatric)\b/.test(t + ' ' + n)) return 'primary_care';
+ return 'specialty_md';
+ }
+ return 'generic';
+}
+
+// ─── Per-type prompt blocks ──────────────────────────────────────────────
+const ORG_TYPE_BLOCKS = {
+ dentist: {
+ team_label: 'Dentists & Hygienists',
+ services_anchor: ['Cleanings & exams', 'Cosmetic dentistry', 'Crowns & bridges', 'Implants', 'Orthodontics', 'Emergency visits'],
+ faqs: [
+ 'Do you take my insurance? — list 2-3 plausible PPO networks (Delta Dental PPO, Cigna, Aetna), explain you\'re out-of-network for HMOs but submit claims',
+ 'How long is a first visit? — exam + x-rays + cleaning, plan-of-care discussion',
+ 'Do you offer sedation? — nitrous, oral sedation, refer for IV',
+ 'Do you see kids? — youngest age you see, co-treat with parent visit',
+ ],
+ footer_disclaimer: 'Information herein is general and not a substitute for an in-person dental exam.',
+ cta_verb: 'Book an exam',
+ avoid: 'NEVER mention "telehealth" — dentistry is in-person',
+ },
+ primary_care: {
+ team_label: 'Physicians & Care Team',
+ services_anchor: ['Annual physicals', 'Acute visits', 'Preventive care', 'Chronic disease management', 'Vaccinations', 'Lab draws on-site'],
+ faqs: [
+ 'Do you take insurance? — list typical PPO acceptance, note out-of-network billing if applicable',
+ 'Same-day appointments? — yes/no policy, after-hours nurse line if any',
+ 'Telehealth available? — for follow-ups and refills, in-person for new visits',
+ 'How do I get records or refills? — patient portal walkthrough',
+ ],
+ footer_disclaimer: 'Information herein is general and not medical advice. For urgent symptoms, call 911 or visit the emergency room.',
+ cta_verb: 'Become a patient',
+ },
+ specialty_md: {
+ team_label: 'Specialists',
+ services_anchor: ['Consultations', 'Diagnostics', 'In-office procedures', 'Surgical referrals', 'Follow-up care', 'Second opinions'],
+ faqs: [
+ 'Do I need a referral? — referral policy by insurance type',
+ 'How quickly can I be seen? — typical first-visit lead time',
+ 'Where do you operate? — affiliated hospital(s)',
+ 'Do you accept my insurance? — typical PPOs accepted',
+ ],
+ footer_disclaimer: 'Information herein is general and not medical advice. Always discuss treatment with a licensed clinician.',
+ cta_verb: 'Schedule a consult',
+ },
+ chiropractor: {
+ team_label: 'Doctor of Chiropractic',
+ services_anchor: ['Spinal adjustment', 'Soft tissue work', 'Decompression', 'Sports injury', 'Auto-accident care', 'Posture & ergonomics'],
+ faqs: [
+ 'How long is a first visit? — exam, films if needed, first treatment ~60 min',
+ 'Do you take auto-injury or workers comp? — yes/no, lien arrangements',
+ 'How many visits will I need? — depends on diagnosis, transparent care plan',
+ 'Do you take insurance? — typical PPO networks, cash-pay rate',
+ ],
+ footer_disclaimer: 'Information herein is general and does not replace clinical evaluation.',
+ cta_verb: 'Book a first visit',
+ },
+ acupuncture: {
+ team_label: 'Practitioner',
+ services_anchor: ['Acupuncture', 'Custom herbal formulas', 'Cupping & gua sha', 'Tuina (medical massage)', 'Moxibustion', 'Dietary therapy'],
+ faqs: [
+ 'What happens at a first visit? — 90-minute intake, tongue + pulse diagnosis, first treatment, custom formula',
+ 'Do the needles hurt? — single-use, hair-fine, most patients sleep through treatment',
+ 'How many sessions to feel better? — depends on chief complaint, transparent typical course',
+ 'Insurance? — out-of-network is most common, superbills issued same day for PPO submission',
+ ],
+ footer_disclaimer: 'Information herein is general; nothing on this page replaces a clinical consultation.',
+ cta_verb: 'Book a first visit',
+ },
+ optometry: {
+ team_label: 'Optometrists',
+ services_anchor: ['Comprehensive eye exam', 'Contact lens fitting', 'Dry eye care', 'Pediatric vision', 'Frames & lenses', 'Same-day glasses'],
+ faqs: [
+ 'Insurance? — VSP, EyeMed, Davis, out-of-network billing',
+ 'How often should I have my eyes checked? — annual for most adults, annual for kids in school',
+ 'Same-day glasses? — single-vision, in-stock frames typically same day',
+ 'Do you fit hard-to-fit contact lenses? — keratoconus / scleral capability',
+ ],
+ footer_disclaimer: 'Information herein is general and not medical advice.',
+ cta_verb: 'Book an exam',
+ },
+ podiatry: {
+ team_label: 'Podiatrist',
+ services_anchor: ['Diabetic foot care', 'Bunion & hammertoe', 'Custom orthotics', 'Sports injury', 'Heel & arch pain', 'Nail care'],
+ faqs: [
+ 'Insurance? — Medicare-accepting and PPO networks',
+ 'Do I need a referral? — by insurance plan',
+ 'How are orthotics made? — scan, fabrication time, fit follow-up',
+ 'Same-day appointments? — for acute pain, yes',
+ ],
+ footer_disclaimer: 'Information herein is general and not medical advice.',
+ cta_verb: 'Schedule a visit',
+ },
+ physical_therapy: {
+ team_label: 'Physical Therapists',
+ services_anchor: ['Post-surgical rehab', 'Sports medicine', 'Manual therapy', 'Dry needling', 'Pelvic floor', 'Telehealth follow-ups'],
+ faqs: [
+ 'Insurance? — typical PPO networks, copay, visits per year',
+ 'Do I need a referral? — direct access in CA, but some insurers require',
+ 'How long is a session? — first 60 min, follow-ups 45 min',
+ 'What should I wear? — comfortable clothing, athletic shoes',
+ ],
+ footer_disclaimer: 'Information herein is general; specific treatment requires evaluation.',
+ cta_verb: 'Book a first visit',
+ },
+ mental_health: {
+ team_label: 'Therapists & Psychiatrists',
+ services_anchor: ['Individual therapy', 'Couples therapy', 'Medication management', 'Telehealth sessions', 'Group therapy', 'Crisis support'],
+ faqs: [
+ 'Insurance? — list PPOs, sliding scale availability, superbills for out-of-network',
+ 'In person or virtual? — both, after first session',
+ 'Confidentiality? — HIPAA, exceptions for safety',
+ 'How long is a session? — 50-min therapy hour, 30-min med-management',
+ ],
+ footer_disclaimer: 'If you are in crisis, call or text 988. Information herein is not a substitute for clinical care.',
+ cta_verb: 'Book a consultation',
+ },
+ vet: {
+ team_label: 'Veterinarians',
+ services_anchor: ['Wellness exams', 'Vaccinations', 'Dental cleanings', 'Surgery', 'Diagnostics', 'Senior pet care'],
+ faqs: [
+ 'New-patient process? — bring records, first visit length',
+ 'Pet insurance? — Trupanion, Healthy Paws, claim help',
+ 'Emergency hours? — daytime and after-hours referral',
+ 'Boarding? — yes/no policy',
+ ],
+ footer_disclaimer: 'For after-hours emergencies, call our partner emergency hospital.',
+ cta_verb: 'Book a visit',
+ },
+ urgent_care: {
+ team_label: 'Clinicians',
+ services_anchor: ['Walk-in care', 'Sports injuries', 'Lacerations & stitches', 'X-ray & labs on-site', 'Occupational health', 'Travel medicine'],
+ faqs: [
+ 'Walk-in or appointment? — both supported, average wait time',
+ 'Insurance? — most PPO networks, self-pay rate',
+ 'When should I go to the ER instead? — chest pain, stroke signs, major trauma',
+ 'Do you do COVID/strep/flu testing? — yes, results timing',
+ ],
+ footer_disclaimer: 'For life-threatening emergencies call 911 or go to the nearest emergency room.',
+ cta_verb: 'Check in online',
+ },
+ hospital: {
+ team_label: 'Care Team',
+ services_anchor: ['Emergency department', 'Inpatient care', 'Surgical services', 'Imaging', 'Specialty clinics', 'Patient & visitor info'],
+ faqs: [
+ 'How do I get to the ER? — directions, parking',
+ 'Visitor hours? — current policy',
+ 'Records request? — release of information process',
+ 'Billing & financial assistance? — link to financial counselor',
+ ],
+ footer_disclaimer: 'For life-threatening emergencies dial 911. Information herein is general.',
+ cta_verb: 'Find a doctor',
+ },
+ eldercare: {
+ team_label: 'Care Team',
+ services_anchor: ['Activities of daily living', 'Memory care', 'Medication management', 'Meals & dining', 'Family communication', 'Tours by appointment'],
+ faqs: [
+ 'How do I tour? — by appointment, virtual option',
+ 'License? — RCFE / ARF / HCO number prominently displayed',
+ 'What is included in monthly fee? — base care, levels of additional care',
+ 'Family communication? — care conferences, app, daily notes',
+ ],
+ footer_disclaimer: 'Licensed by California Department of Social Services Community Care Licensing Division. Information herein is general.',
+ cta_verb: 'Schedule a tour',
+ },
+ salon: {
+ team_label: 'Stylists',
+ services_anchor: ['Cut', 'Color', 'Highlights & balayage', 'Treatments', 'Blowouts', 'Bridal & special event'],
+ faqs: [
+ 'How do I book? — online portal, walk-ins by chair availability',
+ 'Pricing? — starting prices and consultation policy for color',
+ 'Cancellation? — late/no-show policy',
+ 'Products? — line carried in salon, take-home styling',
+ ],
+ footer_disclaimer: '© current year. All services by appointment.',
+ cta_verb: 'Book a stylist',
+ avoid: 'NEVER include "telehealth", "insurance accepted", "medical advice", or any clinical disclaimer language. This is a beauty business, not a clinic.',
+ },
+ spa_aesthetics: {
+ team_label: 'Practitioners',
+ services_anchor: ['Facials', 'Massage', 'Body treatments', 'Skin consultations', 'Membership', 'Gift cards'],
+ faqs: [
+ 'How do I book? — online, gift card use',
+ 'Cancellation? — 24-hour policy',
+ 'Are treatments done by licensed estheticians? — yes, list licenses',
+ 'Memberships? — monthly perks',
+ ],
+ footer_disclaimer: 'Services performed by licensed estheticians. Not a medical treatment.',
+ cta_verb: 'Book a treatment',
+ avoid: 'NEVER include "telehealth" or "insurance accepted" — this is a beauty/wellness service.',
+ },
+ gym_wellness: {
+ team_label: 'Coaches',
+ services_anchor: ['Group classes', 'Personal training', 'Open gym', 'Drop-in policy', 'Memberships', 'Intro week'],
+ faqs: [
+ 'How do I sign up? — drop-in, intro week, membership tiers',
+ 'What should I bring? — water, towel, indoor shoes',
+ 'Cancellation? — class booking and membership freeze policy',
+ 'Beginner-friendly? — onboarding session policy',
+ ],
+ footer_disclaimer: 'Consult a physician before beginning any new exercise program.',
+ cta_verb: 'Try an intro week',
+ avoid: 'NEVER include "telehealth" or "insurance accepted" — this is fitness, not healthcare.',
+ },
+ generic: {
+ team_label: 'The Team',
+ services_anchor: ['What we offer', 'Process', 'Results', 'Pricing', 'Locations', 'Contact'],
+ faqs: [
+ 'How do I get started? — first-step process',
+ 'Pricing? — transparent rate structure',
+ 'Hours & location? — links to map and contact',
+ 'Cancellation policy? — fair and clearly stated',
+ ],
+ footer_disclaimer: '© current year.',
+ cta_verb: 'Get in touch',
+ },
+};
+
+// ─── Variant philosophy (carried from v4, edited) ─────────────────────────
+const VARIANT_PHILOSOPHY = {
+ a: {
+ label: 'Architectural Editorial',
+ pov: `An award-winning architecture monograph, but for this practice. Restrained.
+Deep negative space, asymmetric two-column grids that feel hand-set. The hero is
+large quiet typography over a single duotone gradient (no photo). Section headers
+are small caps with a 1px hairline rule beneath. First paragraph of each section
+gets a drop cap. Numbers are old-style. CTAs are quiet text-with-underline that
+grows on hover.`,
+ structural_constraints: [
+ 'Hero: split 60/40 grid — left = headline (clamp 56-92px) + 1-line subhead + tiny pair of buttons; right = a single duotone gradient block with 1px hairline border, NO photo placeholder labels',
+ 'Section titles in small-caps tracking 0.18em + 1px rule beneath',
+ 'First paragraph of each section: drop cap, 4-line span, in headline serif',
+ 'Asymmetric services grid: 2-1-2 pattern (NOT uniform 3-col)',
+ 'Footnote-style metadata under hero: est. year · neighborhood · practitioner credential',
+ 'Numbers throughout use old-style figures (font-feature-settings: "onum")',
+ ],
+ },
+ b: {
+ label: 'Glass Modernist',
+ pov: `A 2025 product launch page — dark surface, single vibrant accent, geometric
+clarity. Glassmorphism on header (backdrop-filter: blur 12px). Hero has a subtle
+radial-gradient glow. Cards have a 1px rgba(255,255,255,0.08) border and lift 2px
+on hover. ONE accent color used sparingly. Typography is geometric sans, tight
+(-0.02em) at large sizes. Trust band: 4 metrics in horizontal row.`,
+ structural_constraints: [
+ 'Hero: full-width with radial-gradient glow at 30% 0% (palette accent at 12% opacity)',
+ 'h1: 64-80px, weight 600, line-height 1.05, letter-spacing -0.02em',
+ 'Glass header: backdrop-filter: blur(12px), background rgba(palette-bg, 0.7), 1px bottom border at rgba(255,255,255,0.08)',
+ 'CTA: filled accent button + ghost button with 1px border',
+ 'Services 4-col grid; cards rounded-12, 1px border at rgba(255,255,255,0.08), hover translateY(-2px)',
+ 'Process: 3 horizontal steps with thin connector lines, numbered 01/02/03 in accent',
+ ],
+ },
+ c: {
+ label: 'Boutique Hand-set',
+ pov: `A small-batch coffee roaster site, but for this practice. Warm,
+photo-forward, hand-curated. Hero is photo-dominant (large gradient DIV simulating
+a candid practice photo). Headline overlays the photo with a script-display word
+followed by a display-serif phrase. Asymmetric grid throughout. Hand-drawn SVG
+dividers (simple ink stroke paths) between sections. Footer is a personal sign-off.`,
+ structural_constraints: [
+ 'Hero: photo-led full-bleed gradient 16:10 with inner shadow, headline overlays at bottom-left with: <span class="script">Welcome to</span> <h1>...</h1>',
+ 'Script font on first 2-3 words: Pinyon Script or Allura via Google Fonts',
+ 'h1 uses display serif at 56-72px',
+ 'Hand-drawn SVG dividers between sections (inline SVG, simple stroke path)',
+ 'Asymmetric services grid: 1 large card on left + 2 stacked cards on right',
+ 'Pull-quote block with 200px-tall quotation mark in palette accent2',
+ 'Footer: handwritten-style sign-off line ("— the team") in script font',
+ ],
+ },
+};
+
+const ANTI_TELLS = [
+ 'NO em-dashes (use commas, periods, colons)',
+ 'NO words: "elevate", "elevated", "premium-quality", "unparalleled", "world-class", "cutting-edge", "state-of-the-art", "leverage", "empower", "streamline"',
+ 'NO "our team of dedicated professionals", "we are committed to providing", "passionate about"',
+ 'NO emoji in body copy',
+ 'NO Font Awesome icon classes (`<i class="fa-...">`) — use inline SVG icons only',
+ 'NO via.placeholder.com URLs — use a CSS gradient or inline SVG',
+ 'NO "Team Member 1/2/3" placeholders — if no real names, render a single solo-practitioner card',
+ 'Trust band metrics MUST be specific and plausible (e.g. "7 years on this block", "2,400+ patients served", "94% would refer a friend"). NEVER round-numbers like "1000+" or "25 years".',
+];
+
+function pickCombo(orgId, variant) {
+ const variantOffset = variant.charCodeAt(0) - 97;
+ const seed = (Number(orgId) * 31 + variantOffset * 7919) >>> 0;
+ return {
+ template: TEMPLATES[seed % TEMPLATES.length],
+ palette: PALETTES[(seed >>> 3) % PALETTES.length],
+ typePair: TYPE_PAIRS[(seed >>> 7) % TYPE_PAIRS.length],
+ voice: VOICES[(seed >>> 11) % VOICES.length],
+ };
+}
+
+// ─── SVG initials avatars ────────────────────────────────────────────────
+function svgInitialsAvatar(seed, palette) {
+ // Stable 2-letter initials per seed.
+ const h = crypto.createHash('sha256').update(String(seed)).digest();
+ const A = String.fromCharCode(65 + (h[0] % 26));
+ const B = String.fromCharCode(65 + (h[1] % 26));
+ const bg = palette.accent;
+ const fg = palette.bg;
+ const svg = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'><circle cx='100' cy='100' r='100' fill='${bg}'/><text x='50%' y='54%' dominant-baseline='middle' text-anchor='middle' font-family='Georgia,serif' font-size='86' font-weight='400' fill='${fg}' letter-spacing='-2'>${A}${B}</text></svg>`;
+ return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
+}
+
+// ─── Validator ───────────────────────────────────────────────────────────
+const BANNED_WORDS = [
+ /\bexceptional\b/i,
+ /\bpremium\b/i,
+ /\belevat(e|ed|ing)\b/i,
+ /\bworld[- ]class\b/i,
+ /\bcutting[- ]edge\b/i,
+ /\bstate[- ]of[- ]the[- ]art\b/i,
+ /\bleverage\b/i,
+ /\bempower\b/i,
+ /\bstreamline\b/i,
+ /\bunparalleled\b/i,
+ /\bpassionate about\b/i,
+ /our team of dedicated professionals/i,
+ /we are committed to providing/i,
+];
+
+function validateHtml(html, orgType, _o) {
+ const issues = [];
+ // Banned phrases
+ for (const r of BANNED_WORDS) {
+ const m = html.match(r);
+ if (m) issues.push(`banned phrase: "${m[0]}"`);
+ }
+ // Placeholder URLs
+ if (/via\.placeholder\.com|placeholder\.com\/\d|placehold\.co|placeimg\.com|loremflickr\.com/i.test(html)) issues.push('placeholder image URL');
+ // Empty src
+ if (/<img[^>]+src=["']\s*["']/i.test(html)) issues.push('empty image src');
+ // Team-member placeholders
+ if (/\b(Team Member|Doctor|Provider|Practitioner|Member|Stylist|Coach)\s+\d\b/i.test(html)) issues.push('"Team Member N" placeholder name');
+ if (/\b(Dr\.|MD|DDS|DC|LAc|L\.Ac\.)\s+(Lastname|Smith|Doe|Person)\b/i.test(html)) issues.push('placeholder doctor name');
+ // FontAwesome icons without FA loaded
+ const hasFaClasses = /<i\s+class=["'](fa[srlb]?|fab|fas|far|fal)\s+fa-/i.test(html);
+ const hasFaLoaded = /font.?awesome|use\.fontawesome|cdnjs\.cloudflare\.com\/ajax\/libs\/font-awesome/i.test(html);
+ if (hasFaClasses && !hasFaLoaded) issues.push('Font Awesome classes used without FA stylesheet loaded');
+ // Org-type-specific checks
+ const nonMedical = ['salon', 'spa_aesthetics', 'gym_wellness'];
+ if (nonMedical.includes(orgType)) {
+ if (/\b(insurance|copay|telehealth|medical advice|HIPAA|prescription|refill)\b/i.test(html)) issues.push(`non-medical org (${orgType}) page mentions clinical/insurance topics`);
+ }
+ // Beauty/fitness shouldn't have clinical disclaimer
+ if ((orgType === 'salon' || orgType === 'gym_wellness') && /not (medical|clinical) advice|in[- ]person (clinical|medical) (exam|consultation)/i.test(html)) {
+ issues.push(`${orgType} should not carry medical-advice disclaimer`);
+ }
+ // Required tags
+ if (!/<title>[^<]+<\/title>/i.test(html)) issues.push('missing <title>');
+ if (!/<meta\s+name=["']description["']/i.test(html)) issues.push('missing meta description');
+ if (!/application\/ld\+json/i.test(html)) issues.push('missing JSON-LD');
+ return { ok: issues.length === 0, issues };
+}
+
+function fixupAvatars(html, orgId, palette) {
+ // Replace placeholder URLs with deterministic initial monograms.
+ return html
+ .replace(/https?:\/\/(via\.placeholder\.com|placehold\.co|placeimg\.com|loremflickr\.com)[^"' )]+/gi, (_m) => {
+ return svgInitialsAvatar(orgId + '-' + Math.random().toString(36).slice(2, 7), palette);
+ });
+}
+
+// ─── Chat widget (slim) ───────────────────────────────────────────────────
+function chatWidget(orgId, palette) {
+ const acc = palette.accent;
+ return `<style>
+ #vw-chat-fab{position:fixed;right:24px;bottom:24px;z-index:9998;background:${acc};color:#fff;border:0;border-radius:999px;padding:14px 22px;font:600 13px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.04em;text-transform:uppercase;cursor:pointer;box-shadow:0 8px 32px -12px rgba(0,0,0,.4)}
+ #vw-chat-panel{position:fixed;right:24px;bottom:84px;z-index:9999;width:360px;max-width:calc(100vw - 48px);height:520px;max-height:80vh;background:#fff;border:1px solid #e6dfd1;border-radius:14px;box-shadow:0 24px 60px -16px rgba(0,0,0,.28);display:none;flex-direction:column;overflow:hidden;font:14px/1.5 ui-sans-serif,system-ui,sans-serif;color:#1c1f1c}
+ #vw-chat-panel.open{display:flex}
+ #vw-chat-head{padding:14px 18px;background:${acc};color:#fff;display:flex;align-items:center;justify-content:space-between;font-weight:600;font-size:13px;letter-spacing:.04em;text-transform:uppercase}
+ #vw-chat-close{background:transparent;border:0;color:#fff;font-size:18px;cursor:pointer}
+ #vw-chat-log{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
+ #vw-chat-log .vw-msg{max-width:85%;padding:8px 12px;border-radius:12px;font-size:13px;line-height:1.45;white-space:pre-wrap}
+ #vw-chat-log .vw-bot{align-self:flex-start;background:#f4efe2;color:#1c1f1c}
+ #vw-chat-log .vw-me{align-self:flex-end;background:${acc};color:#fff}
+ #vw-chat-form{display:flex;gap:6px;padding:10px;border-top:1px solid #e6dfd1;background:#fbf7f0}
+ #vw-chat-input{flex:1;border:1px solid #e6dfd1;border-radius:8px;padding:9px 12px;font:13px ui-sans-serif,system-ui,sans-serif;outline:none}
+ #vw-chat-send{background:${acc};color:#fff;border:0;border-radius:8px;padding:9px 14px;font:600 12px ui-sans-serif,system-ui,sans-serif;letter-spacing:.04em;text-transform:uppercase;cursor:pointer}
+</style>
+<button id="vw-chat-fab" onclick="document.getElementById('vw-chat-panel').classList.toggle('open')">Ask anything</button>
+<div id="vw-chat-panel" role="dialog"><div id="vw-chat-head"><span>Ask the practice</span><button id="vw-chat-close" onclick="document.getElementById('vw-chat-panel').classList.remove('open')" aria-label="Close">×</button></div><div id="vw-chat-log"><div class="vw-msg vw-bot">Hi! Ask me about hours, services, or location.</div></div><form id="vw-chat-form" onsubmit="return vwChatSend(event)"><input id="vw-chat-input" type="text" placeholder="Type a question…" autocomplete="off"/><button id="vw-chat-send" type="submit">Send</button></form></div>
+<script>(function(){const ORG_ID=${orgId};const API=(window.PD_API_BASE||'')+'/api/chat';const log=()=>document.getElementById('vw-chat-log');const history=[];function add(t,who){const d=document.createElement('div');d.className='vw-msg vw-'+who;d.textContent=t;log().appendChild(d);log().scrollTop=log().scrollHeight;return d}window.vwChatSend=async function(ev){ev.preventDefault();const i=document.getElementById('vw-chat-input');const m=i.value.trim();if(!m)return false;add(m,'me');i.value='';history.push({role:'user',content:m});const t=add('typing…','bot');t.style.opacity='.7';try{const r=await fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({orgId:ORG_ID,message:m,history:history.slice(-6)})});const j=await r.json();t.style.opacity='1';t.textContent=j.reply||'(error: '+(j.error||'no response')+')';if(j.reply)history.push({role:'assistant',content:j.reply})}catch(e){t.style.opacity='1';t.textContent='(network error)'}return false};})();</script>`;
+}
+
+const SYSTEM_PROMPT = `You are an award-winning web designer and healthcare brand strategist. You output ONLY a complete <!doctype html> page — no markdown fences, no preamble, no commentary, no chain-of-thought. ALL CSS in ONE <style> tag in <head>. External assets only via Google Fonts <link>. Include thoughtful SEO meta + a JSON-LD <script type="application/ld+json"> block matching the practice type.
+
+Your work is judged on:
+1. Real editorial restraint — not LLM filler.
+2. Concrete typographic detail — drop caps, small caps, optical sizes, asymmetric grids, ornaments.
+3. Voice in copy — every word feels written by a human who knows this practice.
+4. Trust-band metrics that are SPECIFIC and PLAUSIBLE.
+5. Working semantic HTML5.`;
+
+function buildPrompt(o, orgType, variant, combo, retryNote) {
+ const v = VARIANT_PHILOSOPHY[variant];
+ const block = ORG_TYPE_BLOCKS[orgType] || ORG_TYPE_BLOCKS.generic;
+ const tplBody = (() => {
+ try { return fs.readFileSync(path.join(TPL_DIR, combo.template.body_file), 'utf8').slice(0, 4000); }
+ catch { return ''; }
+ })();
+ const kindLabel = orgType.replace(/_/g, ' ');
+ return `Design a high-craft mobile-friendly landing page for: ${o.name} (a ${kindLabel} in ${o.city || 'Los Angeles'}).
+Phone: ${o.phone || '(310) 555-0100'}
+Address: ${o.address || `${o.city || 'Los Angeles'}, CA`}
+
+VARIANT ${variant.toUpperCase()}: ${v.label}
+PHILOSOPHY:
+${v.pov}
+
+STRUCTURAL CONSTRAINTS (must be visible in output):
+${v.structural_constraints.map((c, i) => ` ${i + 1}. ${c}`).join('\n')}
+
+ORG-TYPE BLOCK (${orgType}):
+- Team label: ${block.team_label}
+- Services anchor list: ${block.services_anchor.join(', ')}
+- CTA verb: ${block.cta_verb}
+- Footer disclaimer: "${block.footer_disclaimer}"
+${block.avoid ? `- AVOID: ${block.avoid}` : ''}
+
+FAQ — pick FOUR from this list, write each as a real Q&A in the practice voice:
+${block.faqs.map((q, i) => ` ${i + 1}. ${q}`).join('\n')}
+
+PALETTE (${combo.palette.id} — ${combo.palette.soul}):
+- background: ${combo.palette.bg}
+- ink: ${combo.palette.ink}
+- accent: ${combo.palette.accent}
+- accent2: ${combo.palette.accent2}
+
+TYPOGRAPHY (${combo.typePair.id}):
+- Headlines: '${combo.typePair.head}' (load via Google Fonts <link>)
+- Body: '${combo.typePair.body}'
+- Ornament: ${combo.typePair.ornament}
+
+VOICE (${combo.voice.id}):
+${combo.voice.description}
+
+ANTI-TELLS — these are LLM giveaways. AVOID:
+${ANTI_TELLS.map(t => '- ' + t).join('\n')}
+
+STRUCTURAL TEMPLATE (top-rated ${combo.template.score}/100 site, for SECTION ORDERING + density inspiration ONLY — IGNORE its colors, fonts, copy):
+Reference: ${combo.template.name} (${combo.template.site})
+--- TEMPLATE EXCERPT ---
+${tplBody}
+--- END ---
+
+REQUIRED SECTIONS in order:
+1. Sticky header (logo wordmark + 3-4 nav links + accent phone CTA)
+2. Hero (per variant philosophy)
+3. Trust band (4 SPECIFIC metrics — see anti-tells)
+4. Services / specialties (use the anchor list above; per variant grid pattern)
+5. ${block.team_label} (1-3 cards. If no real names, render a single solo-practitioner card with a credentials line)
+6. Visit / location (split: address + phone + hours / map placeholder)
+7. FAQ (the 4 you picked above, in practice voice)
+8. Footer (per variant) — include this exact disclaimer somewhere: "${block.footer_disclaimer}"
+
+SEO:
+- <title> "${o.name} — ${kindLabel} in ${o.city || 'LA'}, CA"
+- <meta name="description"> 145-160 chars
+- Open Graph + Twitter card tags
+- JSON-LD with @type matching the org
+
+Self-contained: ALL CSS in one <style> tag. Mobile breakpoint at 768px. Must validate as HTML5.
+
+${retryNote ? `\n!! RETRY — previous attempt failed validation:\n${retryNote}\nFix every issue and regenerate. !!\n` : ''}
+
+Output the complete page now, starting with <!doctype html>:`;
+}
+
+function stripFences(s) {
+ const m = s.match(/```(?:html|HTML)?\s*([\s\S]*?)\s*```/);
+ return (m ? m[1] : s).trim();
+}
+
+function injectChatWidget(html, orgId, palette) {
+ if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, chatWidget(orgId, palette) + '</body>');
+ return html + chatWidget(orgId, palette);
+}
+
+async function callOllama(prompt, system, model) {
+ const ac = new AbortController();
+ const timer = setTimeout(() => ac.abort(), LLM_TIMEOUT_MS);
+ try {
+ const r = await fetch(`${OLLAMA}/api/generate`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ signal: ac.signal,
+ body: JSON.stringify({
+ model, system, prompt, stream: false,
+ options: { temperature: 0.62, top_p: 0.92, num_predict: 9000, num_ctx: 8192 },
+ }),
+ });
+ clearTimeout(timer);
+ if (!r.ok) { console.warn(`[v5] HTTP ${r.status} on ${model}`); return null; }
+ const j = await r.json();
+ return (j.response || '').trim();
+ } catch (e) {
+ clearTimeout(timer);
+ if (e.name === 'AbortError') console.warn(`[v5] timed out on ${model} after ${LLM_TIMEOUT_MS}ms`);
+ else console.warn(`[v5] err on ${model}: ${e.message}`);
+ return null;
+ }
+}
+
+async function generateOne(o, orgType, variant, combo) {
+ const t0 = Date.now();
+ let issues = null, lastHtml = null;
+
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
+ const retryNote = issues ? issues.map(s => ' - ' + s).join('\n') : '';
+ const prompt = buildPrompt(o, orgType, variant, combo, retryNote);
+ // Try primary model on first 2 attempts; fall back on last.
+ const model = attempt < 2 ? MODEL : FALLBACK_MODEL;
+ const raw = await callOllama(prompt, SYSTEM_PROMPT, model);
+ if (!raw) { issues = ['model returned empty']; continue; }
+ let html = stripFences(raw).replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
+ if (!/<!doctype|<html/i.test(html)) { issues = ['no <html> in output']; continue; }
+ html = fixupAvatars(html, o.id, combo.palette);
+ const v = validateHtml(html, orgType, o);
+ lastHtml = html;
+ if (v.ok) {
+ return { html: injectChatWidget(html, o.id, combo.palette), ms: Date.now() - t0, attempt, ok: true, issues: [] };
+ }
+ issues = v.issues;
+ console.warn(`[v5] attempt ${attempt + 1} failed: ${issues.join('; ')}`);
+ }
+ // All retries failed.
+ if (lastHtml) return { html: injectChatWidget(lastHtml, o.id, combo.palette), ms: Date.now() - t0, attempt: MAX_RETRIES + 1, ok: false, issues: issues || [] };
+ return null;
+}
+
+async function main() {
+ let rows;
+ if (ORG_ID) {
+ rows = (await query(`SELECT id, name, type, address, city, state, zip, phone FROM organizations WHERE id = $1`, [ORG_ID])).rows;
+ } else {
+ rows = (await query(`
+ SELECT id, name, type, address, city, state, zip, phone
+ FROM organizations
+ WHERE opted_out = false AND lat IS NOT NULL
+ AND claim_status = 'unclaimed'
+ AND EXISTS (SELECT 1 FROM emails e WHERE e.organization_id = organizations.id)
+ ORDER BY lead_score DESC NULLS LAST, id
+ LIMIT $1`, [LIMIT])).rows;
+ }
+ console.log(`[v5] processing ${rows.length} orgs × ${VARIANTS.length} variants on ${OLLAMA} model=${MODEL} (fallback=${FALLBACK_MODEL}) concurrency=${CONCURRENCY}`);
+ let ok = 0, taint = 0, skip = 0, fail = 0;
+
+ async function processOrg(o) {
+ const orgType = classifyOrgType(o);
+ // Variants stay sequential within an org — keeps a single org's retries
+ // from saturating MS1, and matches existing log cadence per-org.
+ for (const v of VARIANTS) {
+ const out = path.join(OUT_DIR, `${o.id}-${v}-v5.html`);
+ const tainted = path.join(OUT_DIR, `${o.id}-${v}-v5.tainted.html`);
+ if ((fs.existsSync(out) || fs.existsSync(tainted)) && !FORCE) { skip++; continue; }
+ const combo = pickCombo(o.id, v);
+ const tag = `${combo.template.id}/${combo.palette.id}/${combo.typePair.id}/${combo.voice.id}`;
+ const result = await generateOne(o, orgType, v, combo);
+ const head = `[v5] ${String(o.id).padEnd(7)} ${o.name.slice(0, 28).padEnd(28)} ${orgType.padEnd(18)} ${v}=${tag.padEnd(60)}`;
+ if (!result) { console.log(`${head} FAIL`); fail++; continue; }
+ const target = result.ok ? out : (STRICT ? null : tainted);
+ if (target) {
+ fs.writeFileSync(target, result.html);
+ if (result.ok) { console.log(`${head} OK ${(result.ms / 1000).toFixed(1)}s ${(result.html.length / 1024).toFixed(1)}kb a${result.attempt}`); ok++; }
+ else { console.log(`${head} TAINTED ${(result.ms / 1000).toFixed(1)}s [${result.issues.length} issues]`); taint++; }
+ } else {
+ console.log(`${head} STRICT-DROP [${result.issues.length} issues]`); fail++;
+ }
+ // QA log row
+ fs.appendFileSync(QA_PATH, JSON.stringify({
+ ts: new Date().toISOString(), orgId: o.id, name: o.name, orgType, variant: v,
+ combo: tag, ms: result.ms, attempt: result.attempt, ok: result.ok, issues: result.issues,
+ }) + '\n');
+ }
+ }
+
+ // Simple worker-pool over orgs. Each worker pulls the next org from a shared
+ // index; CONCURRENCY workers run in parallel. Avoids p-limit dependency.
+ let cursor = 0;
+ async function worker() {
+ while (cursor < rows.length) {
+ const i = cursor++;
+ try { await processOrg(rows[i]); }
+ catch (e) { console.error(`[v5] org ${rows[i] && rows[i].id} threw:`, e.message); fail++; }
+ }
+ }
+ const workers = Array.from({ length: Math.min(CONCURRENCY, rows.length) }, () => worker());
+ await Promise.all(workers);
+
+ console.log(`[v5] done. ok=${ok} tainted=${taint} skip=${skip} fail=${fail}`);
+ await pool.end();
+}
+
+if (require.main === module) main().catch(async e => { console.error(e); try { await pool.end(); } catch (_) {}; process.exit(1); });
+
+module.exports = { classifyOrgType, ORG_TYPE_BLOCKS, validateHtml, svgInitialsAvatar, fixupAvatars };
diff --git a/scripts/generate-mockups.js b/scripts/generate-mockups.js
new file mode 100644
index 0000000..42793db
--- /dev/null
+++ b/scripts/generate-mockups.js
@@ -0,0 +1,282 @@
+#!/usr/bin/env node
+/**
+ * Generate per-org bespoke website mockups using template-rotation + local LLM.
+ *
+ * For each org in the input set:
+ * 1. Pick template via org.id % N from data/site-templates/top10.json
+ * 2. Inject template body excerpt as STRUCTURAL inspiration (LLM emulates
+ * section ordering + density, ignores the template's colors/fonts)
+ * 3. Apply our healthcare-modern aesthetic (clean cream-on-moss editorial,
+ * OR dark-mode glass, OR warm-rose photo-led — picked by org type)
+ * 4. Inject AI-chat widget (right-bottom floating; posts to /api/chat)
+ * 5. Save to data/mockups/<orgId>.html + record metadata
+ *
+ * Adapted from lawyer-directory-builder/src/enrich/render_mockups.ts.
+ *
+ * Run:
+ * node scripts/generate-mockups.js # default: top 20 by lead_score
+ * LIMIT=50 node scripts/generate-mockups.js
+ * ONLY_DOCTORS=1 node scripts/generate-mockups.js # restrict to medical types
+ * FORCE=1 node scripts/generate-mockups.js # regenerate even if .html exists
+ */
+const fs = require('fs');
+const path = require('path');
+const { fetch } = require('undici');
+const { pool, query } = require('../agents/shared/db');
+
+const LIMIT = process.env.LIMIT ? Number(process.env.LIMIT) : 20;
+const ONLY_DOCTORS = process.env.ONLY_DOCTORS === '1';
+const FORCE = process.env.FORCE === '1';
+
+const DOCTOR_TYPES = ['medical_group','clinic','dental_office','optometrist_office','surgery_center','hospital','fqhc','tcm_clinic','acupuncture_clinic','chiropractor_office','tcm_herbalist','rcfe','ccrc','adult_residential','home_care_agency','home_health','hospice','nursing_facility'];
+
+const OLLAMA = process.env.OLLAMA_BASE || 'http://127.0.0.1:11434';
+const MODEL = process.env.LLM_MOCKUP_MODEL || 'gemma3:12b';
+// gemma3:12b on MS2 takes 90-300s to write 4-6KB of HTML. Budget 6 min/org.
+const LLM_TIMEOUT_MS = Number(process.env.LLM_TIMEOUT_MS || 360_000);
+
+const TPL_DIR = path.resolve(__dirname, '../data/site-templates');
+const OUT_DIR = path.resolve(__dirname, '../data/mockups');
+fs.mkdirSync(OUT_DIR, { recursive: true });
+
+// ─── load template manifest ────────────────────────────────────────────────
+let TEMPLATES = [];
+try { TEMPLATES = JSON.parse(fs.readFileSync(path.join(TPL_DIR, 'top10.json'), 'utf8')); }
+catch (_) { console.error('[gen] no template manifest — run scripts/cache-healthcare-templates.js first'); process.exit(2); }
+
+function pickTemplate(id) {
+ if (!TEMPLATES.length) return null;
+ return TEMPLATES[id % TEMPLATES.length];
+}
+function loadTemplateBody(t) {
+ try { return fs.readFileSync(path.join(TPL_DIR, t.body_file), 'utf8'); }
+ catch { return ''; }
+}
+
+// ─── chat widget HTML — appended to every generated mockup ────────────────
+function chatWidget(orgId) {
+ return `
+<!-- drvouch ai-chat widget -->
+<style>
+ #vw-chat-fab{position:fixed;right:24px;bottom:24px;z-index:9998;background:#28412f;color:#fbf7f0;border:0;border-radius:999px;padding:14px 22px;font:600 13px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.04em;text-transform:uppercase;cursor:pointer;box-shadow:0 8px 32px -12px rgba(0,0,0,.4)}
+ #vw-chat-fab:hover{background:#3f5f4a}
+ #vw-chat-panel{position:fixed;right:24px;bottom:84px;z-index:9999;width:360px;max-width:calc(100vw - 48px);height:520px;max-height:80vh;background:#fff;border:1px solid #e6dfd1;border-radius:14px;box-shadow:0 24px 60px -16px rgba(0,0,0,.28);display:none;flex-direction:column;overflow:hidden;font:14px/1.5 ui-sans-serif,system-ui,sans-serif;color:#1c1f1c}
+ #vw-chat-panel.open{display:flex}
+ #vw-chat-head{padding:14px 18px;background:#28412f;color:#fbf7f0;display:flex;align-items:center;justify-content:space-between;font-weight:600;font-size:13px;letter-spacing:.04em;text-transform:uppercase}
+ #vw-chat-close{background:transparent;border:0;color:#fbf7f0;font-size:18px;cursor:pointer}
+ #vw-chat-log{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
+ #vw-chat-log .vw-msg{max-width:85%;padding:8px 12px;border-radius:12px;font-size:13px;line-height:1.45;white-space:pre-wrap}
+ #vw-chat-log .vw-bot{align-self:flex-start;background:#f4efe2;color:#1c1f1c}
+ #vw-chat-log .vw-me{align-self:flex-end;background:#28412f;color:#fbf7f0}
+ #vw-chat-log .vw-bot.vw-typing{font-style:italic;opacity:.7}
+ #vw-chat-form{display:flex;gap:6px;padding:10px;border-top:1px solid #e6dfd1;background:#fbf7f0}
+ #vw-chat-input{flex:1;border:1px solid #e6dfd1;border-radius:8px;padding:9px 12px;font:13px ui-sans-serif,system-ui,sans-serif;outline:none}
+ #vw-chat-input:focus{border-color:#28412f}
+ #vw-chat-send{background:#28412f;color:#fbf7f0;border:0;border-radius:8px;padding:9px 14px;font:600 12px ui-sans-serif,system-ui,sans-serif;letter-spacing:.04em;text-transform:uppercase;cursor:pointer}
+</style>
+<button id="vw-chat-fab" onclick="document.getElementById('vw-chat-panel').classList.toggle('open')">Ask anything ✦</button>
+<div id="vw-chat-panel" role="dialog" aria-label="Practice chat">
+ <div id="vw-chat-head"><span>Ask the practice</span><button id="vw-chat-close" onclick="document.getElementById('vw-chat-panel').classList.remove('open')" aria-label="Close">×</button></div>
+ <div id="vw-chat-log"><div class="vw-msg vw-bot">Hi! Ask me about hours, services, location, or anything else about this practice.</div></div>
+ <form id="vw-chat-form" onsubmit="return vwChatSend(event)">
+ <input id="vw-chat-input" type="text" placeholder="Type a question…" autocomplete="off" />
+ <button id="vw-chat-send" type="submit">Send</button>
+ </form>
+</div>
+<script>
+(function(){
+ const ORG_ID = ${orgId};
+ const API = (window.PD_API_BASE || '') + '/api/chat';
+ const log = () => document.getElementById('vw-chat-log');
+ const history = [];
+ function add(text, who) {
+ const d = document.createElement('div');
+ d.className = 'vw-msg vw-' + who;
+ d.textContent = text;
+ log().appendChild(d);
+ log().scrollTop = log().scrollHeight;
+ return d;
+ }
+ window.vwChatSend = async function(ev) {
+ ev.preventDefault();
+ const i = document.getElementById('vw-chat-input');
+ const msg = i.value.trim();
+ if (!msg) return false;
+ add(msg, 'me');
+ i.value = '';
+ history.push({ role: 'user', content: msg });
+ const typing = add('typing…', 'bot vw-typing');
+ try {
+ const r = await fetch(API, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ orgId: ORG_ID, message: msg, history: history.slice(-6) }),
+ });
+ const j = await r.json();
+ typing.classList.remove('vw-typing');
+ typing.textContent = j.reply || ('(error: ' + (j.error || 'no response') + ')');
+ if (j.reply) history.push({ role: 'assistant', content: j.reply });
+ } catch (e) {
+ typing.classList.remove('vw-typing');
+ typing.textContent = '(network error — chat backend may be offline)';
+ }
+ return false;
+ };
+})();
+</script>
+`;
+}
+
+// ─── LLM prompt ────────────────────────────────────────────────────────────
+const LLM_SYSTEM = `You are a senior web designer and SEO specialist building a single self-contained landing page for a healthcare practice. Output ONLY a complete, valid <!doctype html> page — no preamble, no markdown fences, no commentary, just the HTML. All CSS in one <style> tag in the head. No external assets except Google Fonts via <link>. Include thoughtful SEO meta + JSON-LD schema (Organization or Dentist or MedicalBusiness as appropriate).`;
+
+function llmPrompt(o, tpl, tplBody) {
+ const city = o.city || 'Los Angeles';
+ const phone = o.phone || '';
+ const addr = o.address || '';
+ const orgName = o.name;
+ const kind = (o.type || 'practice').replace(/_/g, ' ');
+ const tplBlock = tpl && tplBody
+ ? `\n\nSTRUCTURAL TEMPLATE — emulate the SECTION ORDERING + LAYOUT DENSITY of this top-rated (audit ${tpl.score}/100) healthcare site:\nReference: ${tpl.name} (${tpl.site})\n--- TEMPLATE BODY (HTML excerpt — for STRUCTURE inspiration ONLY, do NOT copy text or branding) ---\n${tplBody}\n--- END TEMPLATE ---\n\nFollow the same number/order of sections, the same visual hierarchy, the same component density.\nIGNORE the template's color palette, typography, and copy — apply the aesthetic below instead.\n`
+ : '';
+
+ return `Design a clean, SEO-optimized, mobile-friendly landing page for: ${orgName}
+Practice type: ${kind}
+City: ${city}
+Phone: ${phone || '(310) 555-0100 — placeholder'}
+Address: ${addr || `${city}, CA`}
+
+Aesthetic — modern healthcare editorial, NEVER flashy:
+- Cream + moss-green palette: #fbf7f0 background, #28412f primary, #b48a3a gold accent, #1c1f1c body text
+- Serif headlines (Cormorant Garamond or Iowan Old Style), sans-serif body (Inter)
+- Hero h1: 56-72px, weight 500, line-height 1.05, -0.015em letter-spacing
+- Body: 16px/1.6 Inter weight 400
+- Generous whitespace: 80-120px section padding
+- Subtle hover-lift on cards (translateY(-2px) + soft box-shadow)
+- Rounded corners 8-12px max
+- NO gradient text, NO glow effects, NO drop shadows over 0 8px 24px rgba(0,0,0,0.12)
+- Calm, trustworthy, premium-feel — think Mayo Clinic + One Medical, not flashy
+
+Required sections, in order:
+1. Sticky header — practice name in serif (text logo), 3-4 nav links, phone CTA
+2. Hero — tagline, 1-line subhead, 2 CTAs (filled "${kind === 'dental_office' ? 'Book a Cleaning' : kind === 'optometrist_office' ? 'Book Eye Exam' : 'Book an Appointment'}" + ghost "Learn More")
+3. Trust band — 4 metrics in horizontal row (years serving city / patients seen / 5-star reviews / same-week appointments)
+4. Services grid — 6 cards in 3-column responsive grid, hover-lift, gold-color icon (Unicode: ✦ ✧ ◆ ◇ ★)
+5. Meet the team — placeholder cards for providers (3-4 in a row)
+6. Visit / location — split: address + phone + hours on left, embedded map placeholder on right
+7. FAQ — 4 expand/collapse questions on insurance, scheduling, services, telehealth
+8. Footer — minimal, hairline border, copyright + non-medical-advice disclaimer
+
+SEO requirements:
+- <title>${orgName} — ${kind === 'dental_office' ? 'Dentist' : kind === 'optometrist_office' ? 'Optometrist' : 'Healthcare'} in ${city}, CA</title>
+- <meta name="description"> 145-160 chars, includes practice name + city + primary service
+- Open Graph tags (og:title, og:description, og:type=website)
+- JSON-LD <script type="application/ld+json"> with @type appropriate to ${kind} (Dentist/Optometrist/MedicalBusiness/Hospital), name, address, telephone, openingHoursSpecification
+- semantic HTML (header, nav, main, section, article, footer)
+- alt text on all images (use placeholder gradient divs if no real images)
+
+Self-contained: all CSS in one <style> tag in <head>. Mobile breakpoint at 768px. Must validate as HTML5.${tplBlock}
+
+Output the full HTML now, starting with <!doctype html>:`;
+}
+
+function stripCodeFences(s) {
+ // Lenient: handle ```html\n…\n```, ```\n…\n```, AND missing trailing newlines.
+ const m = s.match(/```(?:html|HTML)?\s*([\s\S]*?)\s*```/);
+ return (m ? m[1] : s).trim();
+}
+
+async function generateLLMHtml(o, signal) {
+ const t = pickTemplate(Number(o.id));
+ const body = t ? loadTemplateBody(t) : '';
+ const t0 = Date.now();
+ try {
+ const r = await fetch(`${OLLAMA}/api/generate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ signal,
+ body: JSON.stringify({
+ model: MODEL,
+ system: LLM_SYSTEM,
+ prompt: llmPrompt(o, t, body),
+ stream: false,
+ options: { temperature: 0.55, top_p: 0.9, num_predict: 6500 },
+ }),
+ });
+ if (!r.ok) { console.warn(`[llm] HTTP ${r.status} for org ${o.id}`); return null; }
+ const j = await r.json();
+ const raw = (j.response || '').trim();
+ if (!raw) return null;
+ let html = stripCodeFences(raw);
+ if (!/<!doctype|<html/i.test(html)) {
+ console.warn(`[llm] org ${o.id} missing <html>`);
+ return null;
+ }
+ // Inject AI chat widget right before </body>.
+ if (/<\/body>/i.test(html)) {
+ html = html.replace(/<\/body>/i, chatWidget(o.id) + '</body>');
+ } else {
+ html += chatWidget(o.id);
+ }
+ return { html, ms: Date.now() - t0, template_id: t?.id || null, template_score: t?.score || null };
+ } catch (e) {
+ if (e.name === 'AbortError') { console.warn(`[llm] org ${o.id} timed out after ${LLM_TIMEOUT_MS}ms`); return null; }
+ console.warn(`[llm] org ${o.id} err: ${e.message}`);
+ return null;
+ }
+}
+
+async function main() {
+ const where = [
+ `o.opted_out = false`,
+ `o.lat IS NOT NULL`,
+ `o.claim_status = 'unclaimed'`,
+ `EXISTS (SELECT 1 FROM emails e WHERE e.organization_id = o.id)`,
+ ];
+ const params = [];
+ if (ONLY_DOCTORS) { where.push(`o.type = ANY($1)`); params.push(DOCTOR_TYPES); }
+
+ const r = await query(`
+ SELECT o.id, o.name, o.type, o.address, o.city, o.state, o.zip, o.phone, o.lead_score
+ FROM organizations o
+ WHERE ${where.join(' AND ')}
+ ORDER BY o.lead_score DESC NULLS LAST, o.id
+ LIMIT $${params.length + 1}
+ `, [...params, LIMIT]);
+
+ console.log(`[gen] processing ${r.rowCount} orgs, ${TEMPLATES.length} templates available, model=${MODEL}`);
+
+ const results = [];
+ for (const o of r.rows) {
+ const out = path.join(OUT_DIR, `${o.id}.html`);
+ if (fs.existsSync(out) && !FORCE) {
+ console.log(`[gen] org ${o.id} ${o.name} — skip (exists, set FORCE=1 to regen)`);
+ results.push({ id: o.id, name: o.name, status: 'cached' });
+ continue;
+ }
+ const ac = new AbortController();
+ const timer = setTimeout(() => ac.abort(), LLM_TIMEOUT_MS);
+ const t = pickTemplate(Number(o.id));
+ process.stdout.write(`[gen] org ${o.id} ${o.name.slice(0, 36).padEnd(36)} tpl=${(t?.id || 'none').padEnd(11)} ... `);
+ const result = await generateLLMHtml(o, ac.signal);
+ clearTimeout(timer);
+ if (!result) { console.log('FAIL'); results.push({ id: o.id, name: o.name, status: 'fail' }); continue; }
+ fs.writeFileSync(out, result.html);
+ console.log(`${(result.ms / 1000).toFixed(1)}s ${(result.html.length / 1024).toFixed(1)}kb`);
+ results.push({ id: o.id, name: o.name, status: 'ok', ms: result.ms, kb: Math.round(result.html.length / 1024), template: result.template_id });
+ }
+
+ // Manifest for the index page.
+ fs.writeFileSync(path.join(OUT_DIR, '_manifest.json'), JSON.stringify({
+ generated_at: new Date().toISOString(),
+ model: MODEL,
+ template_count: TEMPLATES.length,
+ results,
+ }, null, 2));
+
+ const ok = results.filter(r => r.status === 'ok').length;
+ console.log(`\n[gen] done. ok=${ok} cached=${results.filter(r=>r.status==='cached').length} fail=${results.filter(r=>r.status==='fail').length}`);
+ await pool.end();
+}
+
+main().catch(async (e) => { console.error(e); try { await pool.end(); } catch (_) {} process.exit(1); });
diff --git a/scripts/hawk-local.sh b/scripts/hawk-local.sh
new file mode 100755
index 0000000..1e5733f
--- /dev/null
+++ b/scripts/hawk-local.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+# Local PM2 watchdog for Mac Studio 2.
+# Runs every 30m via launchd (~/Library/LaunchAgents/com.steve.pd-hawk.plist).
+# Daemon-level: if `pm2 jlist` fails the daemon is dead → `pm2 resurrect`.
+# Agent-level: any pd-* not "online" → `pm2 restart`.
+set -uo pipefail
+
+PM2=/Users/stevestudio2/.npm-global/bin/pm2
+LOG=/Users/stevestudio2/Projects/professional-directory/logs/hawk.log
+TS() { date +%FT%T%z; }
+
+mkdir -p "$(dirname "$LOG")"
+
+if ! JLIST=$("$PM2" jlist 2>/dev/null); then
+ echo "[$(TS)] DAEMON DOWN — running pm2 resurrect" >> "$LOG"
+ "$PM2" resurrect >> "$LOG" 2>&1
+ exit 0
+fi
+
+DOWN=$(printf '%s' "$JLIST" \
+ | /usr/bin/python3 -c '
+import json, sys
+try:
+ procs = json.load(sys.stdin)
+except Exception:
+ sys.exit(0)
+for p in procs:
+ name = p.get("name", "")
+ status = p.get("pm2_env", {}).get("status", "")
+ if name.startswith("pd-") and status != "online":
+ print(f"{name} {status}")
+')
+
+if [ -n "$DOWN" ]; then
+ echo "[$(TS)] pd-* not online:" >> "$LOG"
+ printf '%s\n' "$DOWN" >> "$LOG"
+ while IFS= read -r line; do
+ name="${line%% *}"
+ "$PM2" restart "$name" >> "$LOG" 2>&1
+ done <<< "$DOWN"
+else
+ echo "[$(TS)] OK — all pd-* online" >> "$LOG"
+fi
diff --git a/scripts/reverse_geocode_orgs.js b/scripts/reverse_geocode_orgs.js
new file mode 100644
index 0000000..7e6aa4a
--- /dev/null
+++ b/scripts/reverse_geocode_orgs.js
@@ -0,0 +1,90 @@
+#!/usr/bin/env node
+/**
+ * Reverse-geocode organizations that have lat/lng but no city or zip.
+ *
+ * Uses OSM Nominatim (free, polite 1 rps with required UA + email).
+ * Backfills city + zip + address (if address was null) so cold-outreach CSV
+ * can be sliced city-by-city for the sales workflow.
+ *
+ * Run:
+ * node scripts/reverse_geocode_orgs.js
+ * LIMIT=200 node scripts/reverse_geocode_orgs.js
+ */
+const { fetch } = require('undici');
+const { pool, query } = require('../agents/shared/db');
+
+const NOMINATIM = 'https://nominatim.openstreetmap.org/reverse';
+const UA = process.env.USER_AGENT
+ || 'ProfessionalDirectoryBot/0.1 (small-biz directory; contact: steve@designerwallcoverings.com)';
+const RPS = 1; // Nominatim ToS
+
+let lastFetch = 0;
+async function gate() {
+ const minInterval = 1000 / RPS;
+ const wait = Math.max(0, lastFetch + minInterval - Date.now());
+ if (wait > 0) await new Promise(r => setTimeout(r, wait));
+ lastFetch = Date.now();
+}
+
+async function reverseOne(lat, lng) {
+ await gate();
+ const url = `${NOMINATIM}?lat=${lat}&lon=${lng}&format=json&zoom=18&addressdetails=1`;
+ try {
+ const r = await fetch(url, {
+ headers: { 'User-Agent': UA, 'Accept-Language': 'en-US' },
+ signal: AbortSignal.timeout(10000),
+ });
+ if (!r.ok) return null;
+ const j = await r.json();
+ const a = j.address || {};
+ return {
+ city: a.city || a.town || a.village || a.hamlet || a.suburb || a.neighbourhood || null,
+ zip: a.postcode || null,
+ county: a.county || null,
+ address: j.display_name || null,
+ };
+ } catch (_) { return null; }
+}
+
+async function loadCandidates(limit) {
+ const r = await query(`
+ SELECT id, name, lat, lng, address, city, zip
+ FROM organizations
+ WHERE lat IS NOT NULL AND lng IS NOT NULL
+ AND (city IS NULL OR zip IS NULL)
+ ORDER BY id
+ ${limit ? `LIMIT ${Number(limit)}` : ''}
+ `, []);
+ return r.rows;
+}
+
+async function main() {
+ const limit = process.env.LIMIT ? Number(process.env.LIMIT) : null;
+ const cands = await loadCandidates(limit);
+ console.log(`[reverse] candidates=${cands.length} rps=${RPS}`);
+
+ let ok = 0, miss = 0;
+ for (const c of cands) {
+ const r = await reverseOne(c.lat, c.lng);
+ if (!r) { miss++; continue; }
+ await query(`
+ UPDATE organizations
+ SET city = COALESCE(city, $2),
+ zip = COALESCE(zip, $3),
+ county = COALESCE(county, $4),
+ updated_at = NOW()
+ WHERE id = $1
+ `, [c.id, r.city, r.zip, r.county]);
+ ok++;
+ if ((ok + miss) % 25 === 0) console.log(`[reverse] ok=${ok} miss=${miss}`);
+ }
+
+ console.log(`[reverse] done. ok=${ok} miss=${miss}`);
+ await pool.end();
+}
+
+main().catch(async (err) => {
+ console.error('[reverse] fatal:', err);
+ try { await pool.end(); } catch (_) {}
+ process.exit(1);
+});
diff --git a/scripts/run-mockups-overnight.sh b/scripts/run-mockups-overnight.sh
new file mode 100755
index 0000000..507d505
--- /dev/null
+++ b/scripts/run-mockups-overnight.sh
@@ -0,0 +1,81 @@
+#!/bin/bash
+# run-mockups-overnight.sh — bulk regenerate 3 variants per org, overnight.
+#
+# Wraps generate-mockups-v3.js with sleep-prevention, progress logging, and
+# graceful resume. Default 200 orgs × 3 variants = 600 mockups. At ~130s per
+# generation, that's ~22 hours single-threaded. With LIMIT=100 it's ~11 hours.
+# Adjust LIMIT to fit the Mac's awake window.
+#
+# Usage:
+# nohup bash scripts/run-mockups-overnight.sh > logs/overnight-$(date +%Y%m%d).log 2>&1 &
+# disown
+#
+# Or with custom limit:
+# LIMIT=50 nohup bash scripts/run-mockups-overnight.sh ...
+#
+# Existing mockups are skipped unless FORCE=1.
+
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+LIMIT="${LIMIT:-100}"
+VARIANTS="${VARIANTS:-a,b,c}"
+FORCE="${FORCE:-0}"
+# Default Ollama target = Mac Studio 1 (192.168.1.133). Steve directive
+# 2026-05-02 after running v3 + v5 + codex on Mac Studio 2 simultaneously
+# froze the box (load avg 30+, 26GB VRAM swapping). Mac Studio 2 is for
+# interactive work; bulk LLM goes to MS1. Override with OLLAMA_BASE=... if
+# MS1 is down or you specifically want local.
+OLLAMA_BASE="${OLLAMA_BASE:-http://192.168.1.133:11434}"
+TS=$(date +%Y%m%d-%H%M)
+LOG="logs/overnight-${TS}.log"
+
+mkdir -p logs
+
+echo "=== overnight mockup batch — start $(date) ==="
+echo " LIMIT=$LIMIT VARIANTS=$VARIANTS FORCE=$FORCE OLLAMA_BASE=$OLLAMA_BASE"
+echo " log: $LOG"
+
+# Prevent Mac sleep while we run; release on exit.
+caffeinate_pid=""
+if command -v caffeinate >/dev/null 2>&1; then
+ caffeinate -i -s &
+ caffeinate_pid=$!
+ echo " caffeinate pid=$caffeinate_pid (preventing sleep)"
+fi
+trap '[ -n "$caffeinate_pid" ] && kill "$caffeinate_pid" 2>/dev/null || true' EXIT
+
+# Run the generator. Output streams to stdout and to LOG via tee.
+# OLLAMA_BASE forwarded so generate-mockups-v3.js hits MS1 by default.
+LIMIT="$LIMIT" VARIANTS="$VARIANTS" FORCE="$FORCE" OLLAMA_BASE="$OLLAMA_BASE" \
+ node scripts/generate-mockups-v3.js 2>&1 | tee "$LOG"
+
+echo "=== overnight mockup batch — done $(date) ==="
+echo "Generated files: $(ls data/mockups/*-?.html 2>/dev/null | wc -l | tr -d ' ')"
+
+# ─── Post-run failure-rate alert (>10% via George) ───
+ok=$(grep -c "kb$" "$LOG" 2>/dev/null || echo 0)
+fail=$(grep -c " FAIL$" "$LOG" 2>/dev/null || echo 0)
+total=$(( ok + fail ))
+if [ "$total" -gt 0 ]; then
+ pct=$(( fail * 100 / total ))
+ echo "Final: $ok ok, $fail fail, ${pct}% failure rate"
+ if [ "$pct" -gt 10 ]; then
+ body="<p>Mockup overnight batch finished with <b>${pct}%</b> failure rate.</p>"
+ body+="<p>OK: $ok · FAIL: $fail · TOTAL: $total</p>"
+ body+="<p>Last 20 FAIL lines:</p><pre>$(grep ' FAIL$' "$LOG" | tail -20 | sed 's/[<>&]/_/g')</pre>"
+ body+="<p>Log: <code>$LOG</code></p>"
+ payload=$(/usr/bin/python3 -c "
+import json, sys
+print(json.dumps({
+ 'to': 'steveabramsdesigns@gmail.com',
+ 'subject': 'pd-mockups overnight: ${pct}% failure rate',
+ 'body': '''$body'''
+}))
+")
+ curl -s -m 10 -u admin:DWSecure2024! -X POST 'http://localhost:9850/api/send' \
+ -H 'Content-Type: application/json' -d "$payload" > /dev/null || \
+ echo "WARN: failed to send George alert"
+ fi
+fi
diff --git a/scripts/score_prospects.js b/scripts/score_prospects.js
new file mode 100644
index 0000000..cf8bf6b
--- /dev/null
+++ b/scripts/score_prospects.js
@@ -0,0 +1,118 @@
+#!/usr/bin/env node
+/**
+ * Lead scoring + chain detection for small-biz organizations.
+ *
+ * Re-runnable. Updates two columns on `organizations`:
+ * • is_chain — TRUE if name matches a known multi-location brand
+ * • lead_score — 0–100, biased toward "ready to pitch a themed website"
+ *
+ * Score recipe (independent signals):
+ * +30 has phone
+ * +25 domain available + needs site
+ * +15 has address
+ * +15 has city
+ * +15 not a chain
+ * −20 if has_website (already has a site → not a prospect)
+ * −60 if is_chain
+ *
+ * Run:
+ * node scripts/score_prospects.js
+ */
+const { pool, query } = require('../agents/shared/db');
+
+// LA-area / national chains we don't want to pitch — small-biz only.
+const CHAIN_PATTERNS = [
+ // Cosmetology
+ /^supercuts\b/i, /^great clips\b/i, /^sport clips\b/i, /^fantastic sams\b/i,
+ /^cost cutters\b/i, /^hair cuttery\b/i, /^cookie cutters\b/i,
+ /^sephora\b/i, /^sephora at\b/i, /^ulta\b/i, /^mac cosmetics\b/i,
+ /^sally beauty\b/i, /^bath\s*&\s*body works\b/i, /^lush\b/i,
+ /^drybar\b/i, /^massage envy\b/i, /^european wax\b/i,
+ /^the(?:\s)?joint\b/i, /^the(?:\s)?joint chiropractic\b/i,
+ /^pacific dental\b/i, /^western dental\b/i, /^aspen dental\b/i, /^smile generation\b/i,
+ /^lenscrafters\b/i, /^pearle vision\b/i, /^visionworks\b/i, /^americas best\b/i,
+ /^banfield\b/i, /^vca\s/i, /^petsmart\b/i, /^petco\b/i,
+ /^ymca\b/i, /^ufc gym\b/i, /^24 hour fitness\b/i, /^lvac\b/i, /^crunch\b/i,
+ /^equinox\b/i, /^ltf\b/i, /^lifetime fitness\b/i, /^orangetheory\b/i,
+ /^f45\b/i, /^pure barre\b/i, /^solidcore\b/i, /^corepower yoga\b/i, /^yogasix\b/i,
+ /^anytime fitness\b/i, /^planet fitness\b/i,
+ /^my eyelab\b/i, /^stanton optical\b/i,
+ /\bcvs\b/i, /\bwalgreens\b/i, /\brite ?aid\b/i,
+];
+
+function classifyChain(name) {
+ if (!name) return false;
+ return CHAIN_PATTERNS.some(rx => rx.test(name));
+}
+
+function score(row) {
+ const hasPhone = !!row.phone && row.phone.trim() !== '';
+ const hasAddress = !!row.address && row.address.trim() !== '';
+ const hasCity = !!row.city && row.city.trim() !== '';
+ const hasSite = !!row.website && row.website.trim() !== '';
+ const domainAv = row.domain_available === true;
+ const isChain = classifyChain(row.name);
+
+ let s = 0;
+ if (hasPhone) s += 30;
+ if (domainAv && !hasSite) s += 25;
+ if (hasAddress) s += 15;
+ if (hasCity) s += 15;
+ if (!isChain) s += 15;
+ if (hasSite) s -= 20;
+ if (isChain) s -= 60;
+ return Math.max(0, Math.min(100, s));
+}
+
+async function main() {
+ // Only score the small-biz universe — the medical orgs from NPI/HCAI aren't
+ // sales prospects.
+ const SMALL_TYPES = [
+ 'salon','barbershop','nail_salon','beauty_supply','spa',
+ 'chiropractor_office',
+ 'acupuncture_clinic','tcm_clinic','tcm_herbalist',
+ 'dental_office',
+ 'optometrist_office','optical_shop',
+ 'yoga_studio','pilates_studio','gym',
+ 'tattoo_studio','piercing_studio',
+ 'vet_clinic','pet_grooming','pet_shop',
+ ];
+ const r = await query(`
+ SELECT id, name, phone, address, city, website, domain_available
+ FROM organizations
+ WHERE type = ANY($1::text[])
+ `, [SMALL_TYPES]);
+ console.log(`[score] candidates=${r.rowCount}`);
+
+ let chains = 0, scored = 0;
+ for (const row of r.rows) {
+ const isChain = classifyChain(row.name);
+ const ls = score(row);
+ if (isChain) chains++;
+ await query(
+ `UPDATE organizations SET is_chain = $2, lead_score = $3 WHERE id = $1`,
+ [row.id, isChain, ls]
+ );
+ scored++;
+ if (scored % 500 === 0) console.log(`[score] processed=${scored} chains=${chains}`);
+ }
+
+ console.log(`[score] done. scored=${scored} chains_flagged=${chains}`);
+
+ // Snapshot top scores for sanity.
+ const top = await query(`
+ SELECT id, name, type, lead_score, phone, suggested_domain, domain_available, city
+ FROM organizations
+ WHERE type = ANY($1::text[])
+ ORDER BY lead_score DESC, name LIMIT 12
+ `, [SMALL_TYPES]);
+ console.table(top.rows);
+
+ await pool.end();
+}
+
+main().catch(async (err) => {
+ console.error('[score] fatal:', err);
+ try { await pool.end(); } catch (_) {}
+ process.exit(1);
+});
diff --git a/scripts/scrape-emails-llm.js b/scripts/scrape-emails-llm.js
new file mode 100644
index 0000000..43b218a
--- /dev/null
+++ b/scripts/scrape-emails-llm.js
@@ -0,0 +1,301 @@
+#!/usr/bin/env node
+/**
+ * LLM-driven email scraper.
+ *
+ * For every websited org with no email yet, fetch the homepage + several
+ * staff/contact-shaped subpaths, then ask qwen3:14b (running on both
+ * Mac Studio 2 [127.0.0.1:11434] and Mac Studio 1 [192.168.1.133:11434])
+ * to identify every email on each page and which person it belongs to.
+ *
+ * Each extracted person email is matched against the professionals already
+ * linked to that org via professional_locations. On match → emails row gets
+ * professional_id. Otherwise → organization_id.
+ *
+ * node scripts/scrape-emails-llm.js
+ * LIMIT=10 node ... # smoke
+ * ONLY_DOCTORS=1 node ... # restrict to doctor-type orgs
+ */
+const cheerio = require('cheerio');
+const { fetch } = require('undici');
+const { pool, query } = require('../agents/shared/db');
+const { fetchCompliant, setHostRateLimit } = require('../agents/shared/compliance');
+
+const LIMIT = process.env.LIMIT ? Number(process.env.LIMIT) : null;
+const ONLY_DOCTORS = process.env.ONLY_DOCTORS === '1';
+
+const DOCTOR_TYPES = ['medical_group','clinic','dental_office','optometrist_office','surgery_center','hospital','fqhc','tcm_clinic','acupuncture_clinic','chiropractor_office','tcm_herbalist','rcfe','ccrc','adult_residential','home_care_agency','home_health','hospice','nursing_facility'];
+
+// Pages most likely to host staff emails.
+const SUBPATHS = [
+ '', '/contact', '/contact-us', '/about', '/about-us',
+ '/team', '/our-team', '/meet-our-team', '/staff',
+ '/providers', '/our-providers', '/doctors', '/our-doctors',
+ '/physicians', '/dentists', '/optometrists',
+ '/leadership', '/who-we-are',
+];
+
+// MS1 (192.168.1.133) is currently saturated by lawyer-directory-builder
+// hammering qwen3:32b. We use MS2 only with gemma3:12b (already loaded,
+// fast, free of contention). Override via OLLAMA_HOSTS / MODEL env.
+const OLLAMA_HOSTS = (process.env.OLLAMA_HOSTS || 'http://127.0.0.1:11434|ms2')
+ .split(',')
+ .map(s => { const [url, label] = s.split('|'); return { url, label: label || url }; });
+const MODEL = process.env.MODEL || 'gemma3:12b';
+
+let hostIdx = 0;
+function nextHost() { const h = OLLAMA_HOSTS[hostIdx % OLLAMA_HOSTS.length]; hostIdx++; return h; }
+
+const EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
+const DECOY_RE = /^(noreply|no-reply|donotreply|do-not-reply|example|test|sentry|webmaster|security|abuse)@/i;
+const VENDOR_DOMAINS = new Set([
+ 'sentry.io','sentry-next.wixpress.com','wix.com','squarespace.com','godaddy.com',
+ 'cloudflare.com','example.com','example.org','tld.com','site.com','domain.com','website.com','sample.com',
+]);
+
+function isPlausibleEmail(e) {
+ if (!e || typeof e !== 'string' || e.length > 100 || !e.includes('@')) return false;
+ if (DECOY_RE.test(e)) return false;
+ const dom = e.split('@')[1];
+ if (!dom || VENDOR_DOMAINS.has(dom.toLowerCase())) return false;
+ if (/\.(png|jpe?g|gif|svg|webp|css|js)$/i.test(e)) return false;
+ if (/[a-f0-9]{32,}/i.test(e.split('@')[0])) return false;
+ return true;
+}
+
+function originOf(websiteRaw) {
+ let s = String(websiteRaw || '').trim();
+ if (!s) return null;
+ if (!/^https?:\/\//i.test(s)) s = 'https://' + s;
+ try { return new URL(s); } catch { return null; }
+}
+
+function plainText($) {
+ $('script, style, noscript, header nav, footer nav').remove();
+ return ($('body').text() || $.text() || '')
+ .replace(/[ \t]+/g, ' ')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim();
+}
+
+async function fetchPageText(url) {
+ try {
+ const res = await fetchCompliant(url, { accept: 'text/html' });
+ if (!res.ok) { if (process.env.LLM_DEBUG) console.error(` fetch ${res.status} ${url}`); return null; }
+ const ct = res.headers.get('content-type') || '';
+ if (!/html|text/i.test(ct)) { if (process.env.LLM_DEBUG) console.error(` fetch non-html ${url}`); return null; }
+ const html = await res.text();
+ if (!html || !html.includes('@')) { if (process.env.LLM_DEBUG) console.error(` fetch no@ ${url}`); return null; }
+ const $ = cheerio.load(html);
+ // Return the FULL plain text — emails are often in the footer, past where
+ // a naive slice would cut. The LLM call truncates separately to fit ctx.
+ return plainText($);
+ } catch (e) {
+ if (process.env.LLM_DEBUG) console.error(` fetch err ${e.code || ''} ${e.message} ${url}`);
+ if (e?.code === 'CAPTCHA_DETECTED') return null;
+ return null;
+ }
+}
+
+async function llmExtract(pageText, orgName, sourceUrl) {
+ const host = nextHost();
+ const prompt = `Extract every email address from this page of "${orgName}" and (when stated) the person each email belongs to.
+
+Return ONLY valid JSON in this exact shape, no commentary:
+{"emails":[{"email":"...","person":"Full Name or null","role":"Title or null","kind":"person|generic"}]}
+
+If none, return: {"emails":[]}.
+
+PAGE TEXT:
+"""
+${pageText.slice(0, 12000)}
+"""`;
+
+ // AbortSignal.timeout occasionally fails to fire under undici when a TCP
+ // connection is established but reads stall — use an explicit AbortController
+ // with our own setTimeout to guarantee abortion.
+ const ac = new AbortController();
+ const killer = setTimeout(() => ac.abort(), 45_000);
+
+ try {
+ const res = await fetch(host.url + '/api/generate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ model: MODEL,
+ prompt,
+ stream: false,
+ format: 'json',
+ keep_alive: '10m',
+ options: { temperature: 0.1, num_ctx: 6144 },
+ }),
+ signal: ac.signal,
+ });
+ clearTimeout(killer);
+ if (!res.ok) return [];
+ const j = await res.json();
+ const out = JSON.parse(j.response || '{}');
+ const emails = Array.isArray(out.emails) ? out.emails : [];
+ return emails.filter(e => e && isPlausibleEmail(e.email)).map(e => ({
+ email: String(e.email).toLowerCase().trim(),
+ person: e.person && String(e.person).trim() || null,
+ role: e.role && String(e.role).trim() || null,
+ kind: e.kind === 'person' ? 'person' : 'generic',
+ page: sourceUrl,
+ via: host.label,
+ }));
+ } catch (e) {
+ clearTimeout(killer);
+ return [];
+ }
+}
+
+function fallbackRegexExtract(pageText, sourceUrl) {
+ if (!pageText) return [];
+ const out = new Map();
+ for (const m of pageText.matchAll(EMAIL_RE)) {
+ const e = String(m[0]).toLowerCase().trim();
+ if (isPlausibleEmail(e) && !out.has(e)) out.set(e, { email: e, person: null, role: null, kind: 'generic', page: sourceUrl, via: 'regex' });
+ }
+ return [...out.values()];
+}
+
+function tokens(s) {
+ return String(s || '').toLowerCase().normalize('NFKD').replace(/[^a-z\s]/g, ' ').split(/\s+/).filter(Boolean);
+}
+
+function nameOverlap(personName, fullName) {
+ if (!personName || !fullName) return 0;
+ const a = new Set(tokens(personName));
+ const b = new Set(tokens(fullName));
+ let hit = 0;
+ for (const t of a) if (b.has(t) && t.length >= 2) hit++;
+ return hit;
+}
+
+async function matchPersonToProfessional(orgId, personName) {
+ if (!personName) return null;
+ const pros = (await query(
+ `SELECT p.id, p.full_name FROM professional_locations pl
+ JOIN professionals p ON p.id = pl.professional_id
+ WHERE pl.organization_id = $1 AND p.opted_out = false`,
+ [orgId]
+ )).rows;
+ if (pros.length === 0) return null;
+ let best = null, bestScore = 0;
+ for (const p of pros) {
+ const s = nameOverlap(personName, p.full_name);
+ if (s > bestScore) { bestScore = s; best = p; }
+ }
+ return bestScore >= 2 ? best : null;
+}
+
+async function processOrg(o) {
+ const origin = originOf(o.website);
+ if (!origin) return { pages: 0, found: 0, person_emails: 0, org_emails: 0 };
+
+ setHostRateLimit(origin.hostname, 1.0);
+
+ const seen = new Map();
+ let pagesHit = 0;
+ let llmCalls = 0;
+ for (const sub of SUBPATHS) {
+ const url = origin.origin + (sub === '' ? '/' : sub);
+ const txt = await fetchPageText(url);
+ if (!txt) continue;
+ pagesHit++;
+ if (process.env.LLM_DEBUG) console.error(` fetched ${url} (${txt.length}B)`);
+
+ // Always grab regex-found emails (cheap, never wrong).
+ const regex = fallbackRegexExtract(txt, url);
+ if (process.env.LLM_DEBUG && regex.length > 0) console.error(` regex found ${regex.length} on ${url}: ${regex.map(e=>e.email).join(',')}`);
+ for (const e of regex) {
+ if (!seen.has(e.email)) seen.set(e.email, e);
+ }
+
+ // Only spend an LLM call when (a) the page has @ AND (b) the page reads
+ // like a team/contact page (so we can attribute to a person). Skip the
+ // LLM entirely on homepage pages where the regex usually suffices.
+ const isStaffShape = /\b(team|staff|provider|doctor|physician|dentist|optometrist|nurse|leadership|about)\b/i.test(sub);
+ if (isStaffShape && llmCalls < 3 && txt.includes('@')) {
+ const extracted = await llmExtract(txt, o.name, url);
+ llmCalls++;
+ for (const e of extracted) {
+ if (!seen.has(e.email)) seen.set(e.email, e);
+ else if (e.kind === 'person' && seen.get(e.email).kind !== 'person') seen.set(e.email, e);
+ }
+ }
+ if (seen.size >= 12 || pagesHit >= 8) break;
+ }
+
+ let personEmails = 0, orgEmails = 0;
+ for (const e of seen.values()) {
+ let proId = null;
+ if (e.kind === 'person') {
+ const m = await matchPersonToProfessional(o.id, e.person);
+ if (m) proId = m.id;
+ }
+ try {
+ if (proId) {
+ await query(
+ `INSERT INTO emails (professional_id, email, email_type, source_url, last_verified_at, verification_status)
+ VALUES ($1, $2, $3, $4, NULL, 'unverified')
+ ON CONFLICT DO NOTHING`,
+ [proId, e.email, e.role || 'office', e.page]
+ );
+ personEmails++;
+ } else {
+ await query(
+ `INSERT INTO emails (organization_id, email, email_type, source_url, last_verified_at, verification_status)
+ VALUES ($1, $2, $3, $4, NULL, 'unverified')
+ ON CONFLICT DO NOTHING`,
+ [o.id, e.email, e.kind === 'person' ? 'staff_unmatched' : 'office', e.page]
+ );
+ orgEmails++;
+ }
+ } catch (_) { /* dup or fk - skip */ }
+ }
+ return { pages: pagesHit, found: seen.size, person_emails: personEmails, org_emails: orgEmails };
+}
+
+async function main() {
+ const where = [
+ `o.website IS NOT NULL`, `o.website <> ''`,
+ `NOT EXISTS (SELECT 1 FROM emails e WHERE e.organization_id = o.id AND e.source_url LIKE 'http%')`,
+ ];
+ if (ONLY_DOCTORS) where.push(`o.type = ANY($1)`);
+ const params = ONLY_DOCTORS ? [DOCTOR_TYPES] : [];
+ const sql = `
+ SELECT o.id, o.name, o.website, o.type
+ FROM organizations o
+ WHERE ${where.join(' AND ')}
+ ORDER BY o.id
+ ${LIMIT ? `LIMIT ${LIMIT}` : ''}
+ `;
+ const r = await query(sql, params);
+ console.log(`[llm-email] candidates=${r.rowCount} hosts=${OLLAMA_HOSTS.map(h => h.label).join('+')}`);
+
+ let orgs = 0, totalPersonEmails = 0, totalOrgEmails = 0, withAny = 0;
+ for (const o of r.rows) {
+ orgs++;
+ try {
+ const stat = await processOrg(o);
+ totalPersonEmails += stat.person_emails;
+ totalOrgEmails += stat.org_emails;
+ if (stat.person_emails + stat.org_emails > 0) withAny++;
+ if (orgs % 5 === 0 || stat.found > 0) {
+ console.log(`[llm-email] orgs=${orgs}/${r.rowCount} pages=${stat.pages} found=${stat.found} (person=${stat.person_emails}, org=${stat.org_emails}) | totals: p=${totalPersonEmails} o=${totalOrgEmails} with_any=${withAny}`);
+ }
+ } catch (e) {
+ console.error(`[llm-email] error org=${o.id} (${o.name}): ${e.message}`);
+ }
+ }
+
+ console.log(`[llm-email] DONE orgs=${orgs} person_emails=${totalPersonEmails} org_emails=${totalOrgEmails} with_any=${withAny}`);
+ await pool.end();
+}
+
+main().catch(async (err) => {
+ console.error('[llm-email] fatal:', err);
+ try { await pool.end(); } catch (_) {}
+ process.exit(1);
+});
diff --git a/scripts/scrape-org-emails.js b/scripts/scrape-org-emails.js
new file mode 100644
index 0000000..b63762f
--- /dev/null
+++ b/scripts/scrape-org-emails.js
@@ -0,0 +1,176 @@
+#!/usr/bin/env node
+/**
+ * Org-website email scraper.
+ *
+ * For every organization with a populated website but no email row yet,
+ * fetch the homepage + /contact + /about and extract any on-domain or
+ * common-vendor email addresses. Politely rate-limited per host via
+ * shared/compliance.fetchCompliant.
+ *
+ * Run:
+ * node scripts/scrape-org-emails.js # all eligible orgs
+ * LIMIT=20 node scripts/scrape-org-emails.js # smoke
+ * ONLY_DOCTORS=1 node scripts/scrape-org-emails.js # restrict to doctor-type orgs
+ */
+const cheerio = require('cheerio');
+const { pool, query } = require('../agents/shared/db');
+const { fetchCompliant, setHostRateLimit } = require('../agents/shared/compliance');
+
+const LIMIT = process.env.LIMIT ? Number(process.env.LIMIT) : null;
+const ONLY_DOCTORS = process.env.ONLY_DOCTORS === '1';
+
+const DOCTOR_TYPES = ['medical_group','clinic','dental_office','optometrist_office','surgery_center','hospital','fqhc','tcm_clinic','acupuncture_clinic','chiropractor_office','tcm_herbalist'];
+
+const SUBPATHS = ['', '/contact', '/contact-us', '/about', '/about-us'];
+
+const EMAIL_RE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
+
+const DECOY_RE = /^(noreply|no-reply|donotreply|do-not-reply|example|test|wix\.com|sentry|godaddy|squarespace|cloudflare|webmaster|admin|support|hello|mail)@/i;
+const VENDOR_DOMAINS = new Set([
+ 'sentry.io','sentry-next.wixpress.com','wix.com','squarespace.com','godaddy.com',
+ 'cloudflare.com','google.com','gmail.com','example.com','example.org','tld.com',
+ 'site.com','domain.com','website.com','someone@example.com','sample.com',
+]);
+
+function normalize(email) {
+ return String(email || '').trim().toLowerCase().replace(/\.$/, '');
+}
+
+function isPlausible(email) {
+ if (!email) return false;
+ if (email.length > 100) return false;
+ if (!email.includes('@')) return false;
+ if (DECOY_RE.test(email)) return false;
+ // Reject literal example domains and obvious vendors.
+ const domain = email.split('@')[1];
+ if (!domain) return false;
+ if (VENDOR_DOMAINS.has(domain)) return false;
+ if (/\.(png|jpe?g|gif|svg|webp|css|js)$/i.test(email)) return false;
+ // Reject anything that looks like a sentry/analytics token.
+ if (/[a-f0-9]{32,}/i.test(email.split('@')[0])) return false;
+ return true;
+}
+
+function originOf(websiteRaw) {
+ let s = String(websiteRaw || '').trim();
+ if (!s) return null;
+ if (!/^https?:\/\//i.test(s)) s = 'https://' + s;
+ try { return new URL(s); } catch { return null; }
+}
+
+async function fetchHtml(url) {
+ try {
+ const res = await fetchCompliant(url, { accept: 'text/html' });
+ if (!res.ok) return null;
+ const ct = res.headers.get('content-type') || '';
+ if (!/html|text/i.test(ct)) return null;
+ const text = await res.text();
+ if (text.length > 800_000) return text.slice(0, 800_000);
+ return text;
+ } catch (e) {
+ if (e?.code === 'CAPTCHA_DETECTED') return null;
+ return null;
+ }
+}
+
+function extractEmails(html, sourceUrl) {
+ if (!html) return [];
+ const $ = cheerio.load(html);
+ const found = new Map(); // email → page
+
+ // Pass 1: explicit mailto: hrefs.
+ $('a[href^="mailto:"]').each((_, el) => {
+ const href = ($(el).attr('href') || '').replace(/^mailto:/i, '').split('?')[0];
+ const e = normalize(href);
+ if (isPlausible(e) && !found.has(e)) found.set(e, sourceUrl);
+ });
+
+ // Pass 2: text-mode addresses anywhere in body. Strip script/style tags first.
+ $('script, style, noscript').remove();
+ const text = $('body').text() || $.text();
+ for (const m of text.matchAll(EMAIL_RE)) {
+ const e = normalize(m[0]);
+ if (isPlausible(e) && !found.has(e)) found.set(e, sourceUrl);
+ }
+
+ return [...found.entries()].map(([email, page]) => ({ email, page }));
+}
+
+async function processOrg(o) {
+ const origin = originOf(o.website);
+ if (!origin) return { found: 0, hit_pages: 0 };
+
+ setHostRateLimit(origin.hostname, 1.0);
+
+ const seen = new Map();
+ let pagesHit = 0;
+ for (const p of SUBPATHS) {
+ const url = origin.origin + p;
+ const html = await fetchHtml(url);
+ if (!html) continue;
+ pagesHit++;
+ for (const { email, page } of extractEmails(html, url)) {
+ if (!seen.has(email)) seen.set(email, page);
+ }
+ if (seen.size >= 5) break; // diminishing returns
+ }
+
+ let inserted = 0;
+ for (const [email, page] of seen) {
+ try {
+ await query(
+ `INSERT INTO emails (organization_id, email, email_type, source_url, last_verified_at, verification_status)
+ VALUES ($1, $2, 'office', $3, NULL, 'unverified')
+ ON CONFLICT DO NOTHING`,
+ [o.id, email, page]
+ );
+ inserted++;
+ } catch (e) {
+ // duplicate or fk error — skip
+ }
+ }
+ return { found: seen.size, inserted, pages: pagesHit };
+}
+
+async function main() {
+ const where = [
+ `o.website IS NOT NULL`,
+ `o.website <> ''`,
+ `NOT EXISTS (SELECT 1 FROM emails e WHERE e.organization_id = o.id)`,
+ ];
+ if (ONLY_DOCTORS) where.push(`o.type = ANY($1)`);
+ const params = ONLY_DOCTORS ? [DOCTOR_TYPES] : [];
+ const sql = `
+ SELECT o.id, o.name, o.website, o.type
+ FROM organizations o
+ WHERE ${where.join(' AND ')}
+ ORDER BY o.id
+ ${LIMIT ? `LIMIT ${LIMIT}` : ''}
+ `;
+ const r = await query(sql, params);
+ console.log(`[email] candidates=${r.rowCount}`);
+
+ let totalFound = 0, totalOrgs = 0, withAny = 0;
+ for (const o of r.rows) {
+ totalOrgs++;
+ try {
+ const stat = await processOrg(o);
+ totalFound += stat.inserted || 0;
+ if (stat.inserted) withAny++;
+ if (totalOrgs % 25 === 0) {
+ console.log(`[email] orgs=${totalOrgs} with_any=${withAny} emails_inserted=${totalFound}`);
+ }
+ } catch (e) {
+ console.error(`[email] error org=${o.id} (${o.name}): ${e.message}`);
+ }
+ }
+
+ console.log(`[email] done. orgs=${totalOrgs} with_any=${withAny} emails_inserted=${totalFound}`);
+ await pool.end();
+}
+
+main().catch(async (err) => {
+ console.error('[email] fatal:', err);
+ try { await pool.end(); } catch (_) {}
+ process.exit(1);
+});
diff --git a/scripts/seed-reviews-from-reddit.js b/scripts/seed-reviews-from-reddit.js
new file mode 100644
index 0000000..e981f44
--- /dev/null
+++ b/scripts/seed-reviews-from-reddit.js
@@ -0,0 +1,78 @@
+#!/usr/bin/env node
+/**
+ * Seed `reviews` from `org_signals` where source='reddit'.
+ *
+ * For each Reddit signal we don't already have a review for:
+ * 1. Send title+body to local Ollama (gemma3:4b) for sentiment + 1-5 score.
+ * 2. Insert a review with source='reddit', source_url=permalink,
+ * reviewer_display=reddit username, scores from LLM analysis.
+ * 3. Skip if we've already seeded that URL.
+ *
+ * Run nightly via launchd (com.steve.pd-reddit-reviews-nightly).
+ */
+const { fetch } = require('undici');
+const { pool, query } = require('../agents/shared/db');
+
+const OLLAMA = process.env.OLLAMA_BASE || 'http://127.0.0.1:11434';
+const MODEL = process.env.SENTIMENT_MODEL || 'gemma3:4b';
+
+async function classifySentiment(title, body) {
+ const text = (title + '\n\n' + body).slice(0, 4000);
+ const prompt = `Classify the sentiment of this Reddit post about a healthcare practice. Output STRICT JSON only:
+{"score": 1-5, "summary": "one sentence summary of the patient's actual experience"}
+
+Score 1=terrible, 3=neutral, 5=excellent. If the post is just a question without an experience, output {"score": null, "summary": "asking for recommendations"}.
+
+Post:
+"""
+${text}
+"""
+
+JSON:`;
+ try {
+ const r = await fetch(`${OLLAMA}/api/generate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ model: MODEL, prompt, stream: false, format: 'json',
+ options: { temperature: 0.2, num_ctx: 4096 } }),
+ signal: AbortSignal.timeout(45_000),
+ });
+ if (!r.ok) return null;
+ const j = await r.json();
+ const out = JSON.parse(j.response || '{}');
+ const score = out.score != null ? Math.max(1, Math.min(5, Math.round(Number(out.score)))) : null;
+ return { score, summary: String(out.summary || '').slice(0, 1000) };
+ } catch { return null; }
+}
+
+async function main() {
+ const r = await query(`
+ SELECT s.id AS signal_id, s.organization_id, s.title, s.body, s.author, s.posted_at, s.url
+ FROM org_signals s
+ WHERE s.source = 'reddit'
+ AND NOT EXISTS (SELECT 1 FROM reviews rv WHERE rv.source_url = s.url)
+ ORDER BY s.id
+ LIMIT ${Number(process.env.LIMIT) || 1000}
+ `);
+ console.log(`[reddit→reviews] candidates=${r.rowCount} model=${MODEL}`);
+
+ let ok = 0, skipped = 0, fail = 0;
+ for (const s of r.rows) {
+ const sent = await classifySentiment(s.title || '', s.body || '');
+ if (!sent || sent.score == null) { skipped++; continue; }
+ try {
+ await query(`
+ INSERT INTO reviews (target_organization_id, reviewer_display,
+ overall_score, body, source, source_url, source_posted_at)
+ VALUES ($1, $2, $3, $4, 'reddit', $5, $6)
+ ON CONFLICT (source_url) DO NOTHING
+ `, [s.organization_id, s.author || 'reddit user', sent.score, (sent.summary || s.title || s.body || '').slice(0, 5000), s.url, s.posted_at || null]);
+ ok++;
+ if (ok % 10 === 0) console.log(`[reddit→reviews] ok=${ok} skipped=${skipped} fail=${fail}`);
+ } catch (e) { fail++; }
+ }
+ console.log(`[reddit→reviews] done. ok=${ok} skipped=${skipped} fail=${fail}`);
+ await pool.end();
+}
+
+main().catch(async e => { console.error(e); try { await pool.end(); } catch (_) {}; process.exit(1); });
diff --git a/scripts/snapshot-mockups.js b/scripts/snapshot-mockups.js
new file mode 100644
index 0000000..1e0247f
--- /dev/null
+++ b/scripts/snapshot-mockups.js
@@ -0,0 +1,50 @@
+#!/usr/bin/env node
+/**
+ * Snapshot every generated mockup to a 1200×750 PNG (so the pitch modal
+ * can show real thumbnails instead of slow live iframes).
+ *
+ * Reads: data/mockups/<id>.html
+ * Writes: data/mockups/<id>.png
+ *
+ * Usage:
+ * node scripts/snapshot-mockups.js # snapshots all mockups missing PNG
+ * FORCE=1 node scripts/snapshot-mockups.js # re-snapshots even if PNG exists
+ */
+const fs = require('fs');
+const path = require('path');
+const { chromium } = require('playwright');
+
+const DIR = path.resolve(__dirname, '../data/mockups');
+const FORCE = process.env.FORCE === '1';
+const VIEWPORT = { width: 1200, height: 750 };
+
+(async () => {
+ const files = fs.readdirSync(DIR).filter(f => f.endsWith('.html'));
+ console.log(`[snap] ${files.length} mockups in ${DIR}`);
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({ viewport: VIEWPORT, deviceScaleFactor: 1 });
+ let ok = 0, skip = 0, fail = 0;
+
+ for (const f of files) {
+ const id = f.replace(/\.html$/, '');
+ const png = path.join(DIR, `${id}.png`);
+ if (fs.existsSync(png) && !FORCE) { skip++; continue; }
+ const url = 'file://' + path.join(DIR, f);
+ const page = await context.newPage();
+ try {
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 20_000 });
+ // Hide our injected chat widget so it doesn't dominate the thumbnail.
+ await page.addStyleTag({ content: '#vw-chat-fab, #vw-chat-panel { display: none !important; }' });
+ await page.waitForTimeout(400); // let any animations settle
+ await page.screenshot({ path: png, fullPage: false, type: 'png' });
+ console.log(`[snap] ${id}.png ✓`);
+ ok++;
+ } catch (e) {
+ console.log(`[snap] ${id} FAIL ${e.message}`);
+ fail++;
+ }
+ await page.close();
+ }
+ await browser.close();
+ console.log(`[snap] done. ok=${ok} skip=${skip} fail=${fail}`);
+})();
diff --git a/src/enrich/ad_signals.js b/src/enrich/ad_signals.js
new file mode 100644
index 0000000..977bacf
--- /dev/null
+++ b/src/enrich/ad_signals.js
@@ -0,0 +1,123 @@
+/**
+ * Ad / social-tracking signal detector — Pass 1 (regex only, no HTTP, no LLM).
+ *
+ * Ported from ventura-corridor's src/enrich/ad_signals.ts. Schema deltas:
+ * - business_id → organization_id
+ * - business_enrichment → org_enrichment
+ * - front_page_audits → unchanged (same shape)
+ *
+ * Detected signals:
+ * google_ads, google_analytics, meta_pixel, tiktok_pixel, pinterest_tag,
+ * linkedin_insight, twitter_pixel, reddit_pixel, bing_uet, snap_pixel,
+ * gtm_container (GTM detected — pixels may be loaded indirectly)
+ *
+ * paid_ads_count = sum of paid-channel booleans (excludes google_analytics,
+ * which is measurement-only, and gtm_container which is a wrapper).
+ *
+ * Compliance: internal sales-targeting only. Never publish per-doctor ad
+ * spend. Never assert dollar amounts — these are presence flags only.
+ */
+const fs = require('node:fs');
+const path = require('node:path');
+const { pool, query } = require('../../agents/shared/db');
+
+const REPO_ROOT = path.resolve(__dirname, '../..');
+
+function detect(html) {
+ const s = {
+ google_ads: /googleadservices\.com|gtag\([^)]*['"]AW-|google_conversion_id|googletagmanager\.com\/gtag\/js\?id=AW-/i.test(html),
+ google_analytics: /gtag\([^)]*['"]G-|googletagmanager\.com\/gtm\.js|google-analytics\.com\/analytics\.js|googletagmanager\.com\/gtag\/js\?id=G-|gtag\([^)]*['"]UA-/i.test(html),
+ meta_pixel: /connect\.facebook\.net\/[^"']*\/fbevents\.js|fbq\(['"]init['"]|fbq\(['"]track['"]|facebook\.com\/tr\?id=/i.test(html),
+ tiktok_pixel: /analytics\.tiktok\.com|business-api\.tiktok\.com|ttq\.load|TiktokAnalyticsObject/i.test(html),
+ pinterest_tag: /s\.pinimg\.com\/ct\.js|pintrk\(['"]load|pintrk\(['"]track/i.test(html),
+ linkedin_insight: /snap\.licdn\.com\/li\.lms-analytics|_linkedin_partner_id|_linkedin_data_partner_ids/i.test(html),
+ twitter_pixel: /static\.ads-twitter\.com\/uwt\.js|analytics\.twitter\.com\/i\/adsct|t\.co\/i\/adsct|twq\(['"]init|twq\(['"]track/i.test(html),
+ reddit_pixel: /www\.redditstatic\.com\/ads\/pixel\.js|rdt\(['"]init|rdt\(['"]track/i.test(html),
+ bing_uet: /bat\.bing\.com|bat\.r\.msn\.com|window\.uetq|window\["uetq"\]|uet\.gif/i.test(html),
+ snap_pixel: /sc-static\.net\/scevent|snaptr\(['"]init/i.test(html),
+ gtm_container: /googletagmanager\.com\/gtm\.js\?id=GTM-/i.test(html),
+ paid_ads_count: 0,
+ };
+ s.paid_ads_count =
+ Number(s.google_ads) +
+ Number(s.meta_pixel) +
+ Number(s.tiktok_pixel) +
+ Number(s.pinterest_tag) +
+ Number(s.twitter_pixel) +
+ Number(s.reddit_pixel) +
+ Number(s.bing_uet) +
+ Number(s.snap_pixel) +
+ Number(s.linkedin_insight);
+ return s;
+}
+
+async function main() {
+ const r = await query(`
+ SELECT DISTINCT ON (a.organization_id) a.organization_id, a.raw_html_path
+ FROM front_page_audits a
+ WHERE a.raw_html_path IS NOT NULL
+ ORDER BY a.organization_id, a.audited_at DESC
+ `);
+ console.log(`[ad-signals] mining ${r.rowCount} HTML files…`);
+
+ let withGoogleAds = 0, withMeta = 0, withTikTok = 0, withPinterest = 0,
+ withLinkedIn = 0, withTwitter = 0, withReddit = 0, withBing = 0,
+ withSnap = 0, withGA = 0, anyPaid = 0, processed = 0;
+
+ for (const row of r.rows) {
+ const rel = row.raw_html_path.startsWith('data/') ? row.raw_html_path : 'data/' + row.raw_html_path;
+ let html;
+ try {
+ html = fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8');
+ } catch {
+ continue;
+ }
+ const s = detect(html);
+ if (s.google_ads) withGoogleAds++;
+ if (s.meta_pixel) withMeta++;
+ if (s.tiktok_pixel) withTikTok++;
+ if (s.pinterest_tag) withPinterest++;
+ if (s.linkedin_insight) withLinkedIn++;
+ if (s.twitter_pixel) withTwitter++;
+ if (s.reddit_pixel) withReddit++;
+ if (s.bing_uet) withBing++;
+ if (s.snap_pixel) withSnap++;
+ if (s.google_analytics) withGA++;
+ if (s.paid_ads_count > 0) anyPaid++;
+ processed++;
+
+ await query(
+ `
+ INSERT INTO org_enrichment (organization_id, ad_signals, signals_updated_at)
+ VALUES ($1, $2, now())
+ ON CONFLICT (organization_id) DO UPDATE
+ SET ad_signals = COALESCE(org_enrichment.ad_signals, '{}'::jsonb) || EXCLUDED.ad_signals,
+ signals_updated_at = now()
+ `,
+ [row.organization_id, JSON.stringify(s)]
+ );
+ }
+
+ console.log(`[ad-signals] processed ${processed} orgs · paid-ad signals:`);
+ console.log(` google_ads : ${withGoogleAds}`);
+ console.log(` meta_pixel : ${withMeta}`);
+ console.log(` tiktok_pixel : ${withTikTok}`);
+ console.log(` pinterest_tag : ${withPinterest}`);
+ console.log(` linkedin_insight: ${withLinkedIn}`);
+ console.log(` twitter_pixel : ${withTwitter}`);
+ console.log(` reddit_pixel : ${withReddit}`);
+ console.log(` bing_uet : ${withBing}`);
+ console.log(` snap_pixel : ${withSnap}`);
+ console.log(` any_paid_ads : ${anyPaid}`);
+ console.log(` google_analytics: ${withGA} (not paid; just measurement)`);
+ await pool.end();
+}
+
+if (require.main === module) {
+ main().catch((e) => {
+ console.error('[ad-signals]', e);
+ process.exit(1);
+ });
+}
+
+module.exports = { detect };
← 81db480 tighten .gitignore: add missing standing-rule patterns (buil
·
back to Professional Directory
·
auto-save: 2026-06-22T21:17:54 (2 files) — detailed-test.js c41166b →