Green Wellness
Changelog
Whatβs new in each release of the scheduling platform
Added
- π©Ί **NEW Book Now β Salesforce Web-to-Lead funnel + dual-write to AuditLog.** Per Doug 2026-05-11: "the salesforce webform connected to the book now link as out main funnel for now, also duplicate the informationm for our system, which we may try to use in real live befoer we port everyhting over". While Stripe + email + SMS env vars are being provisioned on the GW Vercel project, the existing in-app SchedulingWizard (multi-step Stripe checkout) silently fails on those missing rails. New flow routes the Book Now CTA to a lightweight branded modal that POSTs to
/api/leads/book-now, which dual-writes: (1) Salesforce Web-to-Lead (https://webto.salesforce.com/servlet/servlet.WebToLeadwithSF_W2L_OID+ form-urlencoded fields) (2) PostgresAuditLog(BAA-covered, action=LEAD_CAPTURED, staffUserName=book-now-funnel). Sister of/get-startedLeadForm but with modal-wrapped UX + distinct lead source (Web - Book NowvsWEB_LEAD_INTERIM) so SF reports + /admin/audit-log can split funnels. **3 new files**:src/lib/salesforce-w2l.ts(server-side W2L helper β function-resolved env reads per Jensine doctrine, 15s timeout, fail-open on SF errors β returns structured{ok, outcome, statusCode}),src/app/api/leads/book-now/route.ts(POST handler β 3/min/IP rate-limit, honeypot, validation, dual-write),src/components/booking/BookNowFormModal.tsx(modal with patient-type chip + name/email/phone/reason/contact-pref/marketingConsent β matches LeadForm styling). **Feature flag**:NEXT_PUBLIC_BOOK_NOW_MODEcontrols which modal opens on?book=true. Default (unsetorsf-w2l) β SF W2L modal. Set towizardβ reverts to legacy in-app SchedulingWizard (Doug-action when BAAs/Stripe land + he wants to test the full custom-app flow live alongside SF). **Doug-action required**: setSF_W2L_OIDenv var ongreen-wellnessVercel project (find in Salesforce β Setup β Web-to-Lead β Create Form β copy the 15-charoidvalue). Until then,salesforce-w2llogs[salesforce-w2l] SF_W2L_OID env var is unset β leads will save to AuditLog onlyand the form is fail-open β submissions land inaudit_logtable immediately, available at /admin/audit-log filter action=LEAD_CAPTURED+ staffUserName=book-now-funnel. tsc clean.
Fixed
- π‘οΈ **
check-no-module-init-rotatable-envregex fix β closes silent-bypass gap for TypeScript-typed module-init declarations.** Sister-port from cannagent v6.4845. Pre-fix regex missedexport const FOO: SomeType = process.env.BAR(the:type annotation between var name and=broke\w+\s*=) AND multi-line forms whereprocess.env.Xis on the next line (the(?:[^;]*\|\|\s*)?clause assumed||on same line). Real risk: any TypeScript-typed Jensine-class trap silently bypassed the gate. Post-fix GW: 0 offenders across 18 guarded names. 7-stack cross-stack regex fix β sister scc/glw/inv/sureel/VRG same-day evening.
Added
- π‘οΈ **NEW arc-guard
check-vercel-project-link.mjsβ defense against the 2026-05-11 cannagent.ai-vs-greenlife-web.vercel/project.jsonmisroute class.** Pre-push gate compares.vercel/project.json projectNametoEXPECTED_PROJECT_NAME = "green-wellness"; fails the push if mismatched with a fix-command-in-error-message. Wired into.githooks/pre-push(20/20). Sister glw v32.405 + scc v24.605 + cannagent v6.4825 + sureel + VRG same-day. Memory pin:feedback_vercel_project_misroute_recovery.
Added
- π©Ί **
/api/healthaddsaiReadyfield β closes the 4th rail of cross-stack readiness probe doctrine (paymentReady / emailReady / smsReady / aiReady).** Sister of cannagent + inv + sureelaiReady. GW uses Vercel AI SDK'santhropic/claude-sonnet-4.6provider in/api/chat(patient intake assistant); the SDK readsANTHROPIC_API_KEYfrom env at request time. Pre-fix: silentANTHROPIC_API_KEYun-set would result in the chat endpoint streaming an error to the patient with no surface signal in /admin/health or external monitors. Post-fix:aiReady: !!process.env.ANTHROPIC_API_KEY. Doug-action gated β doesn't flip top-levelok(same convention as paymentReady/emailReady/smsReady). Cross-stack readiness probe matrix now closed across all stacks that use AI (cannagent + inv + sureel + GW). tsc clean.
Added
- π©Ί **
/api/healthcronActors.details[]adds canonicaldaysSinceLast+staleAfterDaysfields** β closes cross-stack naming-asymmetry gap. Pre-fix: GW exposedstaleDays: 0.21(which actually held the AGE in days, e.g. 5 hours ago = 0.21 days). The field name suggested a threshold but held the computed age β interpreting the JSON alone required cross-referencing the EXPECTED_CRON_ACTORS registry to know whatstale: true/falsemeant. Inv + VRG both usedaysSinceLastfor the age +staleAfterDaysfor the threshold. **Post-fix**: GW now exposes BOTH (a)daysSinceLast(the canonical age field, sister of inv + VRG) + (b)staleAfterDays(the threshold, sostaleis now interpretable from the JSON alone). LegacystaleDaysfield kept for backwards compatibility with any existing consumers (purely additive β no breaking change). Pure improvement to operator-readability without breaking shape. Memory pin:feedback_cross_stack_health_field_naming_audit(queued). tsc clean.
Added
- π‘οΈ **Wire-in fix:
check-bulk-fanout-throttlearc-guard was on disk but NOT in.githooks/pre-pushchain β closes the 7-stack 'defense on disk but not running' anti-pattern.** Sister anti-pattern bit vrg-website (16 unwired), GW prior (7), sureel (6), inv (4). GW hadscripts/check-bulk-fanout-throttle.mjsfrom earlier cross-stack port but pre-push hook skipped it. Pre-fix: any newPromise.all(recipients.map(send))regression would land silently (HIPAA-grade patient-comm rails β Postmark/Resend/Twilio bulk-send shape). Post-fix: gate appended to.githooks/pre-push(19/19 total). Baseline 0 offenders on GW. **7-stack matrix CLOSED**: VRG (just shipped v9.7.4) + cannagent (v6.4745) + GW (this ship) + sureel + scc (v24.405) + glw (v32.205) + inv all enforcing the gate at push time. Memory pin:feedback_gate_on_disk_not_running_anti_pattern.
Fixed
- π§ **
src/app/api/webhooks/postmark/inbound-email/route.ts:367sendEmail(...).catch(...)fire-and-forget β wrapped inafter()** β caught by NEW arc-guardcheck-after-wrap-external-send(sister-port from cannagent + inv same-day). Bug class: Vercel serverless / Fluid Compute can tear down before an unawaited Promise resolves; sender silently drops β no /admin/errors entry β admin presses button, recipient gets nothing. **Same class as the Jensine 2026-05-11 inv welcome-email cascade.** This site (Postmark inbound-email auto-ack) responds to patient replies to GW's monitored inbox β if the auto-ack drops, the patient never gets a confirmation. HIPAA-grade comm path. Fix: importafterfromnext/server, wrap the send + catch insideafter(async () => { try { await sendEmail(...); } catch ... }). Memory pin:feedback_after_wrap_external_send_doctrine.
Added
- π‘οΈ NEW arc-guard
check-after-wrap-external-send(cross-stack port from cannagent + inv same-day). Locks the fire-and-forget-send class on GW with structural gate enforcement. Allowed patterns: awaited send,after()-wrapped send,Promise.all([...catch fallback]). Forbidden: standalonesend*(...).catch(...)in server actions or route handlers. Wired into.githooks/pre-pushafter thecheck-no-module-init-rotatable-envgate. Post-fix: 0 offenders. Cross-stack arc continuing: cannagent + inv had it; GW + sureel + scc + glw + VRG porting same-day.
Added
- π‘οΈ NEW arc-guard
check-no-module-init-rotatable-env(port from inv + cannagent + scc + glw same-day). Locks the stale-Fluid-Compute-instance env-var trap class on GW with structural gate enforcement. **GW-scoped ROTATABLE_ENV_VARS (18 names)** covers HIPAA-grade patient-comm rotations: Resend / Postmark / AWS SES / Twilio (A2P 10DLC re-registration) / RingCentral (patient call/SMS rail) / Anthropic (AI drafts BAA-pending) / ADMIN_SESSION_SECRET / PATIENT_SESSION_SECRET / CRON_SECRET. **Post-port: 0 offenders** (v2.97.D9 Twilio function-resolution fix earlier today handled the captures). Wired into.githooks/pre-push(strict). Cross-stack arc: inv + cannagent + scc + glw + **GW** all gate-locked structurally against the module-init env-rotation trap class β the class that burned 3hr on inv today (Jensine welcome-email cascade). Memory pin:feedback_env_var_precedence_cross_tenant_trap. tsc clean.
Changed
- π§ **
lib/twilio.tsTwilio creds + client function-resolved** β closes the stale-Fluid-Compute-instance env-var trap on GW SMS path. Cross-stack sister of inv v401.545 + scc v24.005 + glw v31.905 same-day (Twilio function-resolution arc). Pre-fixconst TWILIO_FROM = process.env.TWILIO_PHONE_NUMBER+const client = twilio(SID, TOKEN)at module load captured creds + client ONCE per warm Vercel Fluid Compute instance β admin env rotations (Twilio cred rotation, A2P 10DLC re-registration, BAA renewals) didn't reach existing instances until they cycled (~15min). **HIPAA-relevant**: GW patient-comm rotations are exactly the env-update class this trap eats. Fix:getTwilioFrom()readsprocess.env.TWILIO_PHONE_NUMBERon every call;getTwilioClient()constructs a fresh Twilio client per send (Twilio SDK constructor is cheap β same pattern Resend uses in the inv v401.505 fix). Updated 4 call-sites:isSmsReady()+sendSms()body (2 references to client, 2 to TWILIO_FROM). Memory pin:feedback_env_var_precedence_cross_tenant_trap. tsc clean. Closes the cross-stack Twilio function-resolution arc on GW (was 4-of-5 stacks, now 5-of-5 including GW).
Added
- π‘οΈ **Wired 7 existing GW arc-guards into
.githooks/pre-pushβ defenses that EXISTED but didn't ENFORCE.** Pre-fix audit found 7scripts/check-*.mjsgates present on disk + working but never added to the pre-push gate chain β silently allowing regressions despite the gate code being shipped. Audit shape:comm -23 (ls scripts/check-*.mjs) (grep scripts/check- .githooks/pre-push)β same anti-pattern that cost cannagent during prior /loop sessions (arc-guard shipped without prepush wiring = invisible defense). **All 7 verified clean at strict-zero pre-wire**:check-pii-console-leak(Vercel logs NOT BAA-covered β HIPAA-grade defense; same sister gate that just swept 31 sites on VRG v9.7.0/v9.7.1 + 9 on inv v401.225) Β·check-conflict-markers(3-hour deploy-stall class β INCIDENTS.md 2026-05-08) Β·check-cron-auth-no-x-vercel-cron-bypass(spoofable x-vercel-cron header bypass) Β·check-html-entities-jsx(rendered display fidelity) Β·check-imageresponse-cache-pattern(OG share-card cache class) Β·check-inline-form-action-tuple-discard(Doug-flagged UX: form submits that silently swallow error returns) Β·check-server-actions-async(Next 16 Turbopack"use server"sync-export build-fail class β 3hr deploy stall pattern, cannagent v0.57.0). Post-wire: 30 β 37 gates enforced on every GW push. No code changes β only pre-push wiring + changelog. typecheck clean.
Added
- π‘οΈ
check-force-dynamicarc-guard + **1 real-bug fix onsrc/app/admin/layout.tsx** β cross-stack port FROM cannagent v3.145 + inv. Catches the Next 16 prerender-cache regression class: any page/layout that calls a session-verify helper (verifyAdminSession/verifyProviderSession/verifyPatientSession) MUST also exportconst dynamic = "force-dynamic". Sister of VRG v9.5.11β13 Mariane 404 incident β Next may statically prerender layouts that readcookies(); during build the cookie jar is empty βverifyAdminSession()returns null β unauthenticated layout shell gets baked into the build cache β real authed admins get served the empty shell until next deploy bumps the cache. **HIPAA risk**: a prerender-cache regression on/admin/*could serve admin or patient session-redirect responses to the wrong visitor β this gate is defense-in-depth on top of HMAC session cookie verification. **First-run on GW: 1 real violation caught + fixed in same commit** βsrc/app/admin/layout.tsx(the layout that gates all 100+ admin pages) callscookies()+verifyAdminSession()but had noforce-dynamicexport. 101 pages/layouts scanned post-fix, 0 offenders. Wired into pre-push hook +pnpm check:force-dynamicscript. Memory pin:feedback_force_dynamic_admin_prerender_cache(cannagent's gate origin). GW gate count: 36 β 37.
Added
- π‘οΈ
check-server-action-silent-failarc-guard β cross-stack port from cannagent v6.3805 (9 ships in one session arc against this bug class). Flags barereturn;and barethrow new Error()inside use-server files. Bug class: Next 16 prod silently swallows server-action throws AND silent-return;bails leave the UI with no signal β patient/operator clicks Save and form does nothing visible. Canonical fix: redirect with?surface_err=code, OR return ActionResult tuple, on every failure path. Opt-out trailing comment for best-effort helpers. GW use-server surface: 1 file today, 0 violations. Memory pin: feedback_server_action_throw_masked_in_prod. GW gate count: 35 β 36.
Added
- π‘οΈ
check-formdata-absent-vs-empty-on-updatearc-guard β cross-stack port from cannagent v6.2845 + inv v399.165. Catches FormData server actions that read field-default-empty (formData.get(X) || '') and pass into a partial Prisma update β partial form submissions where caller omits the field silently CLEAR the column. Cannagent v6.2845 origin: CallWrapButtons date-only chip wiped nextAction text every click. Adapted for GW's Prismadb.X.update(...)shape (vs cannagent's Drizzledb.update(table)). 0 candidate sites across src/. GW gate count: 34 β 35.
Added
- π‘οΈ
check-client-imports-no-server-onlyarc-guard β cross-stack port from cannagent v6.2505. Sister of v2.97.D3's check-use-server-exports (the cousin class). Catchesuse-clientcomponents importing modules that have an import-server-only directive. Cannagent v6.2305 incident: client component imported state-options from a server-only module β 4 consecutive Vercel deploys errored over 40 minutes before discovery. GW has 5+ server-only files; preventive port locks the class. 132 use-client files scanned, 0 violations. GW gate count: 33 β 34.
Added
- π‘οΈ **
check-use-server-exportsarc-guard β cross-stack port from VRG + cannagent + inv.** Pre-deploy gate scans every "use server" file insrc/app/+src/lib/and asserts every top-level export is async-function (or type-only erasure, or re-export). Bug class: Next 16 / Turbopack invalidates ALL exports in a "use server" file when ANY top-level export is non-async β the first client component that imports by-name fails the build with 'module has no exports at all', misleadingly. Inv live incident v229.005/v231.005:preview-actions.tshadexport const VMI_ADMIN_PREVIEW_COOKIE = "..."alongside two async actions, 5+ Wen + Sea deploys failed in a row before someone noticed. Fix pattern: move const/sync exports to a sibling non-server module. GW state: 409 files scanned, 0 offenders (preventive port locks the class). GW gate count: 32 β 33.
Added
- π‘οΈ **
check-changelog-uniquearc-guard β cross-stack port from cannagent + VRG.** Pre-deploy gate flags duplicate version strings in the CHANGELOG array. Why it matters: React'sand other UI surfaces usekey={entry.version}for reconciler β duplicate keys collapse entries silently into one render slot. 'Pushed v2.97.X β what changed' becomes ambiguous in git history when two entries share a version. **First run caught 4 pre-existing duplicate version strings in GW changelog** βv2.97.D0+v2.97.C0+v2.97.B0+v2.97.30(each appearing twice on different dates, the version-letter sequence apparently cycled). Allowlisted these 4 legacy collisions inLEGACY_DUPESto avoid audit-trail rewrites; lock NEW collisions at 0. Date-chronological check omitted (GW has pre-existing out-of-order date stamps like v2.93.80/v2.93.75; not worth the audit-trail churn). 626 entries scanned. GW gate count: 31 β 32.
Added
- π‘οΈ **
check-env-example-uniquearc-guard β cross-stack port from cannagent v3.159 + VRG v9.6.94.** Pre-deploy gate flags duplicate column-0 env-var declarations in.env.example. Duplicate blocks carry silent drift risk: a maintainer who updates one block won't necessarily update the other. Cannagent v3.159 had 2 dupes (Stripe scaffold + Demo provisioning) carrying drift risk for ~150 ships before someone noticed. 65 GW declarations scanned, all unique. GW gate count: 30 β 31.
Added
- π‘οΈ **
check-cron-route-getarc-guard β cross-stack port from inv v171.945 + VRG + cannagent.** Pre-deploy gate flags Vercel cron routes that export POST but not GET. Vercel cron sends GET requests by default β without a GET handler, every fire silently 405s, Vercel's cron logs show 'fired', the route returns 405, no work happens, and no error surfaces. inv v171.945 had 3 crons silently 405'ing this way (case-card-stub-check + shift-reminders + till-open/close-check) before someone noticed. Accepted GET shapes: function-form delegating to POST, orexport const GET = POST;assignment. 14 GW cron routes scanned β all clean. Memory pin:feedback_vercel_cron_method_get_default. GW gate count: 29 β 30.
Added
- π‘οΈ **
check-no-unsafe-redirectarc-guard β cross-stack port from inv v397.485 + scc v22.405 + glw v30.405 + cannagent v6.3025.** Pre-deploy gate locks open-redirect attack vector at baseline 0: catchessearchParams.get("returnTo"|"redirect"|"callback"|etc)reads that flow intorouter.push()/router.replace()/redirect()without a leading-slash guard. Real prod incident reference: inv v396.645 (staff /login) + v397.445 (customer-PWA /account/login) both ranrouter.push(searchParams.get("returnTo"))β attacker phishing link with?returnTo=https://evil.comperformed off-origin navigation after legit auth. Fix pattern:safeRedirectPath()helper that validates leading-slash + rejects//,/\,://. GW state: 0 offenders today β preventive port locks the class before a future agent ships a patient-portal-style flow with the bad shape. Memory pin:feedback_open_redirect_safe_redirect_path. Day-one allowlist trap NOT needed (no fs.readFileSync/process.env mentions in changelog entry). GW gate count: 28 β 29.
Fixed
- π **
check-fs-read-bundledfalse-positive on its own changelog entry β exemptedsrc/lib/changelog.ts.** v2.97.C7 added the gate, but C7's changelog entry quoted the literal textfs.readFileSync(process.cwd()...)to describe what the gate catches β and the gate's own regex matched that prose. C7 push blocked at pre-push. Same prose-vs-code class as cannagent v6.3245'scheck-env-vars-documentedfix where the gate matchedprocess.env.INBOUND_SECRET_*inside a release-note explainer. Fix: addsrc/lib/changelog.tsto ALLOWLIST (release notes are prose, not application code that needs static-trace coverage).
Added
- π‘οΈ **
check-fs-read-bundledarc-guard β cross-stack port from inv + scc + glw + sureel + VRG.** Pre-deploy gate flagsfs.readFileSync/fs.readdiretc. with dynamic path args (process.cwd(),path.join,${...},import.meta.url). Bug class: Next 16 only traces files reachable via staticimportstatements β rawfs.readFileSync(process.cwd() + "/foo.md")is opaque to static analysis, so the file isn't bundled into the Vercel function and the runtime read silently catches with no error surfacing. tsc passes CLEAN. GW allowlist:src/lib/migration-drift.ts(prisma/migrations IS bundled) +src/app/admin/launch/page.tsx(fs.readFileSync mention inside a template-literal-rendered shell snippet β documentation text, not actual code). Memory pin:feedback_outputFileTracingIncludes_for_fs_reads. Wired into.githooks/pre-push. Baseline 0. GW gate count: 26 β 27.
Added
- π‘οΈ **
check-metadata-exclusivearc-guard β cross-stack port from VRG v9.6.91 + cannagent.** Pre-deploy gate scans everysrc/app/**/page.tsx+src/app/**/layout.tsxfor files exporting BOTHconst metadataANDgenerateMetadata(Next.js App Router rejects this combo: 'You are exporting both metadata and generateMetadata from "". Use one or the other'). vitest passes, tsc passes β failure only surfaces at Next.js build pass when the deploy is already stuck. VRG had the originating incident at v9.6.27 (~30 min recovery). 101 page/layout files scanned today, 0 collisions. Wired into .githooks/pre-push. GW gate count: 25 β 26.
Fixed
- β±οΈ **
src/lib/gbp.tsβ 6 unprotected Google Business Profilefetch()sites getAbortSignal.timeout(10_000).** Continuation of the v2.97.C3 (cron) + v2.97.C4 (10 API-route blob/RC fetches) timeout audit. The GBP integration was the holdout lib: OAuth token-exchange (2 sites: code + refresh), accounts-list, location-list, reviews-list (paginated), and dailyMetrics performance fetch β all 6 ran with no per-request timeout. Pre-fix, a hung Google API endpoint (regional outage, rate-limit penalty, network blip) would block the function until Vercel'smaxDurationkilled it β thepull-gbp-reviewscron would burn its fire window watching a TCP idle, and the OAuth callback during initial setup would hang the operator UI indefinitely. Sister of v2.97.B-N earlier in this version chain that hardened lib/practicefusion.ts (3) + lib/salesforce.ts (2) + lib/email.ts (2) + lib/ringcentral.ts (3) + lib/workflow.ts (1) + lib/ratelimit.ts (1). gbp.ts was the only lib left at 0 timeouts. tsc clean. Past-saturation lane:for f in lib/*.ts; FETCH=$(grep -c 'await fetch(' $f); SIG=$(grep -c 'AbortSignal.timeout' $f); echo $f $FETCH $SIGmade the gap visible.
Fixed
- β±οΈ **10 unprotected blob/RC
fetch()sites in API routes β addedAbortSignal.timeout(10_000)to each.** Sister of v2.97.C3 (RC webhook cron) but in patient-facing + provider-facing API routes (not crons). Pre-fix sites:/api/admin/messages/[messageId]/attachments/[attId](attachment download),/api/admin/messages/[id]/recording(2 β RC OAuth + RC recording fetch),/api/admin/appointments/approve(signature for PDF generation),/api/admin/cert/[id](cert PDF proxy),/api/provider/bulk-approve(signature for bulk-approved PDFs),/api/provider/action(signature for per-row approval),/api/provider/cert-preview/[appointmentId](signature for preview),/api/provider/documents/[id](doc PDF proxy),/api/patient/cert/[id](patient cert download),/api/dispensary/cert/[token](dispensary cert verification). Pre-fix a hung Vercel Blob endpoint (network blip, regional outage) would freeze the function until Vercel'smaxDurationkilled it β patients/providers stuck at 'still loading'. Under Fluid Compute the hung fetch could also tie up a function instance shared across concurrent requests, multiplying the impact. Same recipe as cannagent v6.3285 (base-usdc.ts) β sister-fetches drifted on the timeout field, audit caught the asymmetry. Past-saturation lane discovered viagrep "fetch(" --include="*.ts" src/+ filter on external-host calls in API routes (not crons, not /api-relative client fetches).
Fixed
- β±οΈ **
rc-webhook-renewcron β 5 bare RingCentralfetch()calls gained 10s timeouts.** Cross-stack port from cannagent v6.3285. Pre-fix the cron made 5 unprotectedfetch()calls (OAuth token + list-subscriptions + per-sub renew + per-sub delete + per-sub recreate). If any hit a hung RingCentral endpoint, the cron blocked until Vercel'smaxDurationkilled the function β wasting the fire window + leaving subsequent subs un-renewed. Without renewals, RC webhook subscriptions expire after 7 days and stop delivering SMS/voice events silently. Fix:signal: AbortSignal.timeout(10_000)on all 5 sites. Sister of inv's 6 cron fetches which all have AbortSignal coverage (verified during cross-stack audit).
Added
- π **Migration-drift detector:
orphanedAppliedfield β sister-port from VRG v9.6.82.** Pre-fixcheckMigrationDrift()only surfacedpending(in source but not applied) β the original Doug-2026-05-07 'forgot to migrate' case. Silently missed the reverse drift class: migrations applied to the DB but NOT in source (deleted file after applied,prisma db pushbypass, manual SQL). Real evidence on VRG: prod reportedapplied=48 / expected=47 / ok:truefor unknown duration β 1 orphan was invisible. GW is currently inschema-pushmode so the gap doesn't surface here today, but the defense is shipped for when GW switches to migration-mode (any futureprisma migrate dev+ committed migrations folder). Surface-only β does NOT flipokto false because orphan-source is non-fatal at runtime (the DB has the schema; source just lost the audit trail). Cross-stack port queue: inv + cannagent still pending.
Fixed
- π‘ **
/llms-full.txtcache header** β sister-port from cannagent v6.3225 + sureel + vrg v0.14.25. Pre-fix the public/ static-file fallback servedCache-Control: public, max-age=0, must-revalidateso every Claude / ChatGPT browse / Perplexity / Bing crawler hit on the 80+ line AI-manifest body was a fresh function invocation. Now:public, max-age=3600, s-maxage=3600innext.config.tsheaders() (matches/llms.txtroute-handler's max-age=86400 intent without overriding its longer cache). The Vercel edge cache will now hold the response for 1hr; AI crawlers don't get a free DDOS of the function on every fetch.
Added
- π‘οΈ **
check-imageresponse-cache-patternpre-push gate** β cross-stack arc-guard port from cannagent v6.0605 (referenced memory pinfeedback_imageresponse_cache_pattern). Detects ImageResponse routes (fromnext/og) that setCache-Control: ...max-age=0...WITHOUT a pairedVercel-CDN-Cache-Control/CDN-Cache-Controlcarrying s-maxage. Bug class: edge-runtime ImageResponse silently stripss-maxage+stale-while-revalidatefrom the wire when passed via theheadersoption β share-preview crawlers (Twitter/LinkedIn/Slack) re-render via Satori uncached on every fetch. Cannagent's 2026-05-10 incident: every probed OG endpoint servedmax-age=0until the v6.0605 fix wave. GW today (live-probed 2026-05-11): all 5 ImageResponse routes (icon.tsx + apple-icon.tsx + opengraph-image.tsx + icon-192.png/route.tsx + api/og/route.tsx) serve correct s-maxage via Next.js defaults β no current violation. **Locked at baseline 0** across 461 scanned files. Gate is GW-adapted: scans ALL files importingnext/og(not just opengraph-image.tsx by Next.js file convention) + grants escape hatch when layeredVercel-CDN-Cache-Control/CDN-Cache-Controlcarries s-maxage (the canonical pattern atsrc/app/api/og/route.tsx). Wired into.git/hooks/pre-pushbuild-gate umbrella (now 5/5).
Added
- π‘οΈ **
check-conflict-markerspre-push gate** β cross-stack arc-guard port from cannagent v6.0785 + inv post-2026-05-08-incident. Asserts no source file undersrc/contains an unresolved git merge-conflict marker triple (<<<<<<</=======/>>>>>>>or diff3-mode|||||||). Why this matters: Vercel's build pipeline parses each source file BEFORE tsc runs, so unresolved markers fail the deploy at the parser layer withParsing ecmascript source code failed.β pre-push tsc looks CLEAN while the actual deploy fails. Inv's 2026-05-08 incident stalled the deploy queue for 3+ hours / 9+ commits before the markers were diagnosed inCustomerLookup.tsx. GW has lower parallel-session edit density than cannagent but a HIPAA-scoped deploy queue is the worst place to discover this class. **Locked at baseline 0** across 462 scanned files on first run. Wired into.git/hooks/pre-pushbuild-gate umbrella (now 4/4) +package.json(check:conflict-markers+check:all). Skipsnode_modules/+.next/+fixtures/+ markdown (docs quote markers as teaching examples).
Added
- β
**Required-field asterisks on the get-started LeadForm** (Mariane QA 2026-05-10 #4). Pre-fix the form had
requiredattributes on First name + Last name but no visible indicator β patients submitted without seeing which fields were optional vs not, leading torequiredbrowser-validation pops mid-flow. Now both required-field labels carry a rose*glyph witharia-hidden(since the visible asterisk is decorative βsr-only(required) text +aria-required="true"carry the semantic). Disclaimer at the bottom of the form now closes the loop withRequired fields marked with *. - π¬ **Marketing-consent opt-in checkbox** (Mariane QA 2026-05-10 #11). Separate from the existing clinical-callback consent (which is implicit-on-submit per the disclaimer). HIPAA-adjacent doctrine on marketing comms is explicit opt-in β never pre-checked. Captured in
marketingConsentboolean on the form payload + sent to/api/leads+ persisted in the audit-logLEAD_CAPTURED.detailasmarketingConsent=true/false(no dedicated DB column yet β followup ship when patient-record table grows a marketing-prefs surface; for now the audit row is the SoT for reconciliation). Marketing comms infrastructure MUST NOT fire to a patient without this flag = true at lead-capture.
Fixed
- π **Provider Authorization Queue showed empty when dashboard said
1 Appointment Awaiting Provider Authorization** (Mariane QA 2026-05-10). The dashboard card linked to/admin/appointments?status=PENDING_APPROVALbut that page defaulted to a date window oftoday PT β +30dβ any past-dated PENDING_APPROVAL row (e.g. an appointment from yesterday that the provider hasn't authorized yet) was filtered out. PENDING_APPROVAL is a queue state, not a time state. **Fix:** when?status=PENDING_APPROVALis in the URL and the caller didn't pass explicitfrom=/to=, widen the default window to-365d β +365dso all rows the dashboard counts are actually visible. Other status filters keep the todayβ+30d default (the operational view staff actually uses). No PHI exposure change (admin already sees all appointments β this just unblocks the queue UI).
Added
- π‘οΈ NEW arc-guard
scripts/check-cron-auth-no-x-vercel-cron-bypass.mjsβ sister port (inv + cannagent v6.0465 same evening). Pins memory pinfeedback_x_vercel_cron_header_alone_unsafeagainst re-introduction. Bug class:if (req.headers.get("x-vercel-cron") === "1") return true;β Vercel doesn't strip x-vercel-cron from external requests, so any caller can curl with the header and bypass auth. **HIPAA stakes on GW**: every cron touches patient-comms (renewals / intake-reminders / DOH-nudges / no-show follow-up) β spoofed firing = unauthenticated PHI-touching writes, possible duplicate sends to patients. GW'slib/cron-auth.tsalready correctly enforces Bearer (timingSafeEqual); guard prevents future drift. Skips comments. 14 cron routes scanned, 0 spoofable bypass shapes today. Wired intocheck:allbuild-gate suite. Memory-pin β arc-guard mining lane round 2 β second cross-stack arc-guard ported this evening (after inline-form-action-tuple-discard v2.97.B5).
Added
- π‘οΈ NEW arc-guard
scripts/check-inline-form-action-tuple-discard.mjsβ cross-stack sister port (cannagent v6.0365 + inv v397.085 same evening). Pins memory pinfeedback_inline_form_action_discards_tupleagainst re-introduction. Bug class:swallows{ok:false, error:'...'}returns from Server Actions β user clicks Submit, action rejects, UI shows no change + no error. **HIPAA stakes on GW specifically**: a silent action rejection on a patient appointment / cert update creates a compliance gap (what the chart says happened β what actually wrote). GW historically clean β never had this anti-pattern. 235 .tsx/.jsx files scanned, 0 offenders. Wired intocheck:allbuild-gate suite. False-positive bypass:// eslint-disable-lineon the form-action line. Memory pin β arc-guard mining lane perfeedback_memory_pin_to_arcguard_recipe.md.
Changed
- π StepPayment.tsx + Step1Qualify.tsx β 3 copy fixes flagged by comms-expert audit pass tonight. (1) Payment-down fallback copy was 'Sorry for the friction. Our team has been notifiedβ¦' β apologized for a Stripe-vendor outage (brand-voice violation: never apologize for things that aren't our fault). Now: 'We'll have payment back online soon. The call gets you booked just as fast β usually faster.' β names the path-forward instead of grovels. (2) Deferred-payment confirm copy buried the load-bearing fact ('Your appointment is held') behind an internal-narrative lead ('We're onboarding our payment system' β patient doesn't care). Now leads with what the patient needs to hear first: 'Your appointment is held. Staff will call within 24 hours to confirm and take paymentβ¦'. (3) Step1Qualify disqualification copy read like a state statute citation ('Washington State requires patients to be 18 or olderβ¦'). Now: 'You need to be 18 to get a medical authorization in Washington β that's state law, not our rule. Call us when you're closer to that milestone and we'll get you in.' β 'not our rule' displaces the rejection, and the door-left-open ending invites a future call. Same shape for non-WA-resident: 'If you're moving here or visiting long-term, give us a call β we can talk through your options.' Auditor: communications-expert subagent. tsc clean.
Fixed
- π
/admin/cronserver action βx-forwarded-protoheader may arrive as a comma-separated chain (https,http) when multiple proxies sit in front of Vercel. Pre-fix the value would interpolate as-is into the URL string yieldinghttps,http://greenwellness.org/api/cron/, which throws onfetch(). Now:.split(",")[0]?.trim()to take the first hop only. Caught by an Explore audit pass after Doug pinged 'bugs' on the v2.97.B1 ship. Latent β would only trigger if Vercel's network topology added an upstream proxy, but the cost of the defensive split is zero. tsc clean.
Added
- π§
/admin/cronpage β new 'Run all N skipped' button at the top of the Skipped-today section. Sequentially (not in parallel β protects email/db load) fires every cron in the skipped list using the same per-actor server action under the hood; reuses the role gate + whitelist + 25s timeout per cron. Hard cap of 20 actors per batch as a safety floor. Closes the click-friction loop on v2.97.B1 β when this evening's deploy flurry skipped 7 daily crons, Doug-action shrinks from 7 clicks to 1. NewrunCronBatchAction()+RunAllSkippedButtoncomponent; returns{attempted, succeeded, failed, results[]}so the UI shows per-actor verdicts post-batch.
Added
- π§ AdminNav β
/admin/cron(Cron Health) added under the Admin group. Pre-this-ship the v2.97.B1 page existed but wasn't reachable from any nav surface β Doug or an admin would have had to know the URL. Added with the Wrench icon (signals 'operator tooling' vs the Admin-group's other Activity/Package/Settings icons). Also opened/admin/cronfor MANAGER role inROLE_ALLOWED(matches the server-action's MANAGER/ADMIN gate); SCHEDULER and BOOKKEEPER stay locked out. Closes the v2.97.B1 discoverability gap so the 7-cron backfill is one nav-click away instead of a typed URL.
Added
- π§ NEW
/admin/cronpage + one-click backfill β closes the operator loop on the v2.97.AX skippedToday detection. Pre-this-page, when Vercel cron skipped a fire window (this evening's deploy flurry skipped 7 daily crons: renewals Β· daily-briefing Β· intake-reminder Β· doh-nudge Β· new-patient-drip Β· review-request Β· rc-webhook-renew), the only backfill paths were (a)curl /api/cron/withAuthorization: Bearer $CRON_SECRET(operator must paste the Sensitive-flagged Vercel env var into terminal β exposure risk + tooling friction), or (b) wait for tomorrow's natural fire (1 lost patient-comms day). Now: authed admin opens/admin/cron, sees the inline triage (skippedToday + stale + healthy sections sourced from /api/health), clicks 'Run now' per actor. Server actionrunCronNowAction()atapps/web/src/app/admin/cron/actions.tsvalidates the admin session at the action level (re-reads x-admin-id from headers β defense in depth alongside the layout guard), looks up the path from a whitelist (14 actors, can't be coerced into arbitrary URL), then fetches the cron URL same-origin withAuthorization: Bearer ${process.env.CRON_SECRET}server-side β secret never reaches the client. Audit-logged as newADMIN_CRON_FIREaction (detail =actor=on success, error message on failure). Page also surfaces the broader readiness snapshot β paymentReady + emailReady + emailProvider + smsReady β so the operator can see if a backfill is going to succeed before clicking (e.g. running review-request when emailReady=false is wasted load). tsc clean.status= durationMs=
Changed
- π©Ή StepPayment.tsx β Phase 2 of the v2.97.AV booking-flow incident response. Pre-this-ship: when /api/stripe/intent returns the structured 503
{error:'payment_unavailable'}(Stripe key empty, the actual incident shape), the wizard treated it identically to a generic load failure and showed 'Unable to load the payment form. Please go back and try again' β copy that pushes the patient back into the same broken loop. Post-this-ship: parses the structured error body. Whenerror === 'payment_unavailable'(or stripePromise null = NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY missing), renders 'Online payment is temporarily unavailable' with two prominent CTAs β direct call AND/get-startedlead-form callback β instead of the dead-end retry copy. Adds apaymentUnavailablestate distinct fromloadError. Generic load failures (network, malformed JSON, etc.) still get the existing 'go back and try again' message. Closes the patient-experience loop on the v2.97.AV incident: STRIPE_SECRET_KEY paste is still the load-bearing fix, but until that lands, every patient who hits the wizard now sees a clear path to actually book vs. bouncing. Sister of v2.97.AW WizardErrorBoundary 3-CTA fallback (which catches React render errors; this catches the upstream API 503 the wizard was previously masking). tsc clean.
Added
- π‘
/api/healthnow exportsemailReady,emailProvider, andsmsReadyβ sister probes to the v2.97.AYpaymentReadyflag, completing the three-rail patient-comms readiness lens. **emailReady** =activeProvider() !== 'none'(true when any of POSTMARK_API_KEY / AWS_SES envs / RESEND_API_KEY is set). **emailProvider** echoes which provider would actually be used ('postmark' | 'ses' | 'resend' | 'none') so HIPAA-readiness is a one-curl check too β'resend'means transactional-only path is live but BAA-covered Postmark hasn't been wired (gates patient-PHI email at scale). **smsReady** = TWILIO_ACCOUNT_SID + TWILIO_AUTH_TOKEN + TWILIO_PHONE_NUMBER all set (mirrors the gate at lib/twilio.ts:13 client construction). All three flags + theirisEmailReady()/isSmsReady()exports are reusable elsewhere (e.g. a future/admin/healthwidget). None of these failing trips the top-levelokflag β Doug-action gated, not infra failure (same precedent as cron staleness + paymentReady). Pre-incident value: silent comms-rail failure today is only discoverable when a patient calls in saying 'I never got a confirmation email'; with these flags it's grep-able from any monitoring tool. tsc clean.
Added
- π³
/api/healthnow exports a top-levelpaymentReady: booleanflag β true whenSTRIPE_SECRET_KEYresolves to a realsk_β¦value, false when it's unset/empty/placeholder. Direct response to this evening's v2.97.AV incident β STRIPE_SECRET_KEY went empty on prod, every booking 500-ed at the payment step, and the only way to discover it was to attempt a real booking. With this flag,/admin/healthcan render a visible 'Stripe unconfigured' badge inline + thepaymentReady=falsestate is grep-able in any monitoring tool curling/api/health. Logic:resolvedKey !== STRIPE_KEY_PLACEHOLDER && resolvedKey.startsWith('sk_'). Sister of cronActorsstaleflag β false NEVER fails the top-levelok:trueinfra contract (Doug-action gated, not infra failure).isPaymentReady()exported fromlib/stripe.tsso the same probe can be reused elsewhere (e.g. a future homepage banner if the key drops out mid-day). tsc clean.
Added
- π©Ί
/api/healthcronActors detection now surfaces a per-cronskippedToday: booleanfield. True when a daily-cadence cron (staleAfterDays β€ 3) hasn't fired in >24h. Catches the v2.97.APβAU deploy-flurry-skip class β daily crons can be 32h late and still NOT trip the existingstaleflag (which uses a multi-day threshold to absorb expected weekly drift). Thestalemask hid 7 GW skips during this evening's deploy flurry that an inline 1-day detector would have flagged immediately. Hourly / sub-daily crons (waitlist, reminders) are neverskippedToday=truebecause their staleAfterDays β€1; the broaderstaleflag catches them. Sun-onlyslotsis alwaysskippedToday=false(>3d threshold). Sister of thestalefield β both shipped in same response shape so/admin/healthcan render two distinct badges (stalefor multi-day drift,skippedTodayfor single-day deploy-flurry). tsc clean.
Changed
- πͺ WizardErrorBoundary fallback now offers 3 CTAs instead of 1: (1) Try Again (existing) β recovers when the error was transient. (2) NEW 'Leave us your info β we'll call you back' link β
/get-startedweb-to-lead form (already-shipped fallback that captures name/email/phone/reason and routes to admin manual followup). (3) Phone-call link (existing). Pre-fix the modal only showed Try Again + Close + an inline phone link; patients hitting a hard wizard error during the Stripe-key incident (v2.97.AV) had to either retry-into-same-error OR call. Now they have a one-click way to drop their info and get a callback. Closes the lead-loss vector even when the wizard itself is broken. Doug greenlit 2026-05-10 evening: 'unless you can find them and map it all in to a webto lead form for now' β/get-startedalready exists, this just wires it into the wizard fallback.
Fixed
- π¨ BOOKING FLOW BROKEN β
/api/stripe/intentwas 500-ing on EVERY request becauseSTRIPE_SECRET_KEYis unset/empty on Vercel prod, solib/stripe.tsfalls back tosk_test_placeholderβ¦and Stripe SDK rejects withStripeAuthenticationError. Wizard error boundary catches β 'Something went wrong' modal β patient calls instead OR bounces. Real lead-loss cause caught by Doug's screenshot 2026-05-10 evening. **Two fixes**: (1)lib/stripe.tsnow logs a clear[lib/stripe] STRIPE_SECRET_KEY is unset/empty on productionwarning at module-import time β first payment-touching request reveals the root cause in Vercel logs instead of an opaqueStripeAuthenticationError. (2)/api/stripe/intentnow detects the auth-error shape (StripeAuthenticationError name OR 'Invalid API Key' / 'sk_test_placeholder' message) and returns a structured 503 +{ error: 'payment_unavailable', message: 'Payment system temporarily unavailable. Please call us to complete your booking.' }so the wizard can render a clearer fallback CTA pointing to the phone number instead of a generic error modal. **Doug-action TODAY**: Vercel dashboard β green-wellness project β Settings β Environment Variables β STRIPE_SECRET_KEY β paste the livesk_live_β¦key from Stripe dashboard β save β redeploy. Without that, no patient can complete a booking. tsc clean.
Fixed
- π Session-secret separation β remove ADMIN_SESSION_SECRET fallback from provider / patient / dispensary session signers. Pre-fix all three used
XXX_SESSION_SECRET ?? ADMIN_SESSION_SECRET. If the dedicated secret wasn't set on Vercel, sessions were signed with the admin HMAC key β anyone with admin secret access could forge valid provider / patient / dispensary cookies and impersonate those roles. HIPAA concern: admin β patient impersonation bypasses audit-trail attribution. Verified all four secrets are already set distinctly on Vercel prod (via vercel env ls), so this is a code-side hardening with no env-var deploy required. Dev-mode retains admin fallback for ergonomics.
Added
- π Defense-in-depth requireAdminFromHeaders guard added to all remaining unguarded admin API routes (62 files across appointments, calendar, cert-requests, daily-briefing, dispensaries, email-preview, email-status, eod-email, feedback, integrations/gbp, leads, locations, mailing, messages, outreach, patients, promo-codes, providers, schedules, settings, slots, smoke-tests, users, waitlist, weekly-digest). Inline requireAdmin() helpers and cookie-based verifyAdminSession calls replaced with the canonical header-based guard. ADMIN-only surfaces enforce requireAdminFromHeaders(["ADMIN"]). tsc clean.
Added
- π requireAdminFromHeaders defense-in-depth guard applied to 4 more PHI-bearing message + patient operations: /api/admin/messages/send (outbound SMS+email composer), /api/admin/messages/call (RingCentral click-to-call), /api/admin/messages/ai-draft (Claude reply composer with PHI context), /api/admin/patients/bulk-remind (bulk reminder fanout). proxy.ts is still primary gate; route-level header check fails closed if middleware were ever bypassed.
Fixed
- π /api/patient/auth/set-password β close race-condition replay window. Pre-fix (v2.97.AO) the route checked
if (patient.passwordHash)BEFORE the bcrypt+UPDATE; two parallel requests with the same valid portal token both saw passwordHash=null, both computed bcrypt (~100ms each), both reached the UPDATE, last-write-wins overwrote the first patient's password. Now: atomicupdateMany({ where: { id, passwordHash: null }, data })β collapses to a single SQL UPDATE WHERE passwordHash IS NULL. First wins (count=1); second sees count=0 and gets 409. Pre-check retained for fast-fail on legitimate retries (saves bcrypt compute).
Added
- π Defense-in-depth requireAdminFromHeaders() guard added to 6 high-PHI admin routes: /api/admin/audit-log/export (audit trail CSV), /api/admin/reports/export (bulk report + patient export), /api/admin/reports/eod/export (EOD report CSV), /api/admin/mailing/labels (patient mailing address PDF), /api/admin/messages/[messageId]/attachments/[attId] (patient file proxy), /api/admin/import/patients (bulk patient import POST). Middleware proxy.ts remains the primary gate; this layer ensures a future matcher misconfiguration fails closed instead of silently serving PHI.
Fixed
- π lib/cron-auth: remove
x-vercel-cron: 1as sole-sufficient auth path. Vercel does not strip that header from external requests β accepting it alone let any caller bypass CRON_SECRET and trigger any of the 14 cron routes (reminders, renewals, drip emails, DOH nudges, etc.) without a secret. Pre-fix the header was Path 1; post-fix only Bearer-token + x-internal-secret are accepted. Vercel-scheduled fires still work: Vercel sends the Authorization-Bearer header alongside x-vercel-cron when CRON_SECRET is set.
Fixed
- π /api/patient/auth/set-password: reject with 409 if patient already has a passwordHash. Pre-fix the HMAC portal token was time-bounded (15 min) but not single-use β an attacker who captured the magic-link URL could replay it within the window to override a just-set password. Post-fix the endpoint is a no-op once a password exists; patients are directed to the login page or to request a new link.
Fixed
- π§Ύ Patient document uploads β orphan-blob cleanup on /api/intake/[token]/documents + /api/my-appointments/[token]/documents. Pre-fix the blob upload was wrapped in try-catch but the subsequent
db.medicalDocument.createwas not β a DB insert failure after a successful blob upload would orphan the file in Vercel Blob (still billing, no metadata to surface or delete from /admin/documents). Now: DB insert is wrapped, on failure the blob is best-effort deleted via del() and the patient sees a 502 prompting retry. PHI-safe error logging throughout.
Added
- π‘οΈ Defense-in-depth requireAdminFromHeaders guard applied to 5 more PHI-bearing /api/admin/* routes that previously relied solely on proxy.ts middleware: patients/search (GET), patients (PATCH), patients/notes (PATCH), patients/[id]/messages (GET), accounting/export (GET). accounting/export preserves BOOKKEEPER role access via requireAdminFromHeaders(["ADMIN","MANAGER","BOOKKEEPER"]) and its manual headers() role check is removed in favour of the shared helper.
Fixed
- π /api/patient/auth/forgot-password: silent catch block now logs error name on sendEmail failure. Pre-fix: email delivery failures were swallowed entirely β no trace in Vercel logs. If Postmark is misconfigured patients see 'check your email' but no email arrives and there is zero observability. Fix: same pattern as admin + provider forgot-password (err.name only β PHI redaction: sendEmail errors carry patient.email + reset html body).
Added
- π‘οΈ NEW src/lib/admin-route-guard.ts β shared
requireAdminFromHeaders()helper that re-checksx-admin-id+x-admin-roleat the route level (defense-in-depth on top of proxy.ts middleware gating). Applied to 5 PHI-bearing /api/admin/* routes that previously relied solely on middleware: documents/[id] (GET + DELETE), patients/export, appointments/export, messages, today. cert/[id] retrofitted to use the helper too β single source of truth.
Fixed
- π©Ί /api/admin/cert/[id] HIPAA defense-in-depth: route now verifies x-admin-id + x-admin-role headers (set by proxy.ts after admin session validation) before serving the patient cert PDF. proxy.ts middleware already gates /api/admin/* in the request lifecycle, but layered checks at the route level fail-closed if middleware were ever misconfigured or bypassed. Audit log already auto-captures the admin actor via x-admin-id header (existing audit.ts behavior).
Fixed
- π Stripe webhook signature failures now return 401 (auth failure) instead of 400 (bad request). Stripe retries on both, but the status code is the only observability signal that distinguishes signature-verification failures from payload-shape errors in dashboard logs.
Fixed
- π Appointment reminder crons: two resilience fixes. (1) Per-appointment try-catch in reminders + reminders-2h β a logWorkflowEvent DB failure on one appointment previously crashed the entire batch, leaving all remaining appointments un-reminded for that window. (2) Status re-check before send β patient may cancel between the batch query and the email/SMS send; now skip if status changed from SCHEDULED/CONFIRMED.
Added
- π PII console-leak arc-guard: NEW
scripts/check-pii-console-leak.mjsβ catchesconsole.error("msg", err)with raw Error object. HIPAA: Vercel function logs are NOT BAA-covered; Salesforce/RingCentral/Postmark/Resend errors echo patient email/phone/appointment context into err.message. Fixed 1 violation:src/components/scheduling/WizardErrorBoundary.tsx:27βcomponentDidCatch(error)changed rawerrorβerror instanceof Error ? error.name : String(error). Guard wired intocheck:allchain. Sister glw v21.305 + scc v13.9005.
Fixed
- π /admin/launch: added GW_TEST_EMAIL row to Integrations section β the env var was documented in .env.example (v2.97.AE) but not on the on-site launch checklist, so it was invisible to operators configuring Vercel.
Fixed
- π .env.example: added GW_TEST_EMAIL with usage comment (env-var documentation gap β the test-mode fallback was wired in v2.97.AB but the variable wasn't discoverable without reading source code).
Fixed
- π€ HTML entity violations swept from JSX β 9 named/numeric entities (
,–,—,’) replaced with direct Unicode characters per React's JSX non-decoding behavior (non-XML entities render literally). Ports 2 arc-guards from cannagent: check-html-entities-jsx + check-server-actions-async. Guard wired into pre-push chain.
Added
- π€ /llms-full.txt β long-form AI-search reference for Green Wellness Medical. Closes Doug-action #23 (GW portion). Comprehensive public-facts-only content: at-a-glance, services+pricing, how the WA medical cannabis program works, all qualifying conditions (RCW 69.51A), all 4 clinic locations with addresses (Lynnwood 4720 200th St SW Β· Spokane Valley 323 E 2nd Ave Suite 201H Β· Olympia 1212 4th Ave E Β· Vancouver Clark County), HIPAA privacy summary, and index links to all 34 /learn articles. All content sourced from public-facing site + publicly documented WA statute β no PHI. public/llms.txt updated with Full Reference link pointing to /llms-full.txt.
Fixed
- π§ͺ Test-mode email redirect now works immediately via GW_TEST_EMAIL env var fallback. Prior gap: TEST_EMAIL_REDIRECT_BY_USER_ID is empty until Doug pastes in userId values, so the toggle showed 'TEST MODE' but no redirect actually fired β real patients still got emails during QA. Fix: resolveTestRedirect now checks process.env.GW_TEST_EMAIL as a fallback when the per-user map has no match. Per-user map still takes precedence (overrides env var when userId is in the map). Doug-action: set GW_TEST_EMAIL=admin@greenwellness.org (or similar) on Vercel so the toggle is immediately usable; later paste userIds into TEST_EMAIL_REDIRECT_BY_USER_ID for per-admin routing. lookupTestRedirect pure function unchanged β existing test coverage unaffected.
Changed
- βΏ PWA manifest
lang: "en-US"+dir: "ltr"fields β accessibility signal for screen readers + Lighthouse PWA audit per W3C web-app-manifest spec. T164 audit caught the gap on the 4 non-vrg sites (vrg already had these). Sister glw v21.005 + scc v13.8705 + sureel same-tick quartet β board item #25 (cross-stack PWA manifest field parity). Screenshots field is the remaining piece β needs Doug-captured asset work (wide-format 1280Γ720 + narrow-format 750Γ1334 captures of /, /apply, /my-appointments), left open for now.
Fixed
- π‘οΈ 404 page metadata fix β
src/app/not-found.tsxnow exportsmetadata = { title: "Page not found", robots: { index: false, follow: false } }. Pre-fix the 404 page inherited the layout's default title template (producing real-page SERP titles on 404 URLs) AND was implicitly indexable β soft-404 / thin-content risk. Now: explicit "Page not found" title + robots:noindex closes both gaps. Sister glw v20.905 + scc v13.8605 same-fix triple β board item #21 (cross-stack 404 metadata parity, agent-pickable).
Changed
- π§ͺ Lift pure-function
lookupTestRedirect(userId, testModeOn, map?)out of v2.97.W0 resolveTestRedirect β testable in isolation without mocking cookies()/verifyAdminSession/dynamic imports. Default map arg = TEST_EMAIL_REDIRECT_BY_USER_ID; tests can pass their own. Returns null on (a) testModeOn=false, (b) missing userId, (c) userId not in map. resolveTestRedirect now delegates to it. No behavior change. Sets up the contract pin: future refactor of the redirect-decision logic must update this function + its (forthcoming) test suite. Sister of the v392.685+v393.085 chain-of-custody writerβreader pattern from inv β same defensive pin shape.
Fixed
- π Restore GW
vercel.jsoncrons[] post-Mariane QA (test-mode set 2026-05-09 23:36 PT, 14h+ ago). All 14 scheduled crons re-armed: reminders / reminders-2h / no-show / renewals / daily-briefing / weekly-digest / intake-reminder / eod-email / doh-nudge / new-patient-drip / review-request / rc-webhook-renew / waitlist / slots. Restored verbatim fromgit show eab2c3e:vercel.jsonper the OWNER_ACTION_QUEUE handoff. Patient SMS reminders + appointment renewals + weekly digest emails resume on the next cron firing window.
Added
- πͺ Test-mode email-redirect toggle for QA β Doug 2026-05-10: 'have there be a way mariane and i can toggle between test environment and live'. NEW floating chip in the admin top-right (
TestModeToggle.tsx). Off (default) = emerald 'Live' label, prod sends. On = amber 'TEST MODE' label, sendEmail (inlib/email.ts) redirects all outbound patient emails to the logged-in admin's mailbox perTEST_EMAIL_REDIRECT_BY_USER_IDmap. Subject prefixed[TEST β original@addr]so original recipient is preserved for forensic check. Cookie-based (gw_test_mode=on); cron-fired emails have no request context so they always send to real patients (production-safe by default). Resolver wraps cookies()/session lookups in try/catch β outside-request-context (background scripts, edge) falls through to no-redirect. **Doug-action**: paste the AdminUser.id values for Doug + Mariane intoTEST_EMAIL_REDIRECT_BY_USER_IDinsrc/lib/email.ts(currently empty β fail-closed prod-safe). Pull from/admin/usersafter login.
Added
- π‘ NEW RSS 2.0 feed at
/feed.xmlfor /learn medical-cannabis education content (34 articles). Pre-fix the site published 34 articles with no machine-readable feed β Feedly/NewsBlur/Inoreader subscribers + AI-training crawlers (GPTBot, ClaudeBot, PerplexityBot all probe /feed.xml + /rss.xml) all 404'd. Cross-repo port from glw v8.485 + scc v10.605 (T82 round-3 work). Layoutadded so feed readers auto-discover when users paste the homepage URL. HIPAA-safe: ARTICLES are general medical-cannabis education content (qualifying conditions, evaluation process, WA State authorization) β no PHI, no patient-specific data, no provider identifiers beyond the public clinic-roster page. RSS 2.0 + atom:link self-reference + escapeXml on free-text fields + 30-min edge cache + 1-hour stale-while-revalidate. Closes Doug-action #27 cross-stack RSS parity gap (T171 finding). tsc clean.
Fixed
- π‘οΈ NEW
report-uri /api/csp-reportdirective added to enforce-mode CSP β closes the silent-violation gap. Pre-fix every blocked-request violation vanished without telemetry; Doug had no visibility into what enforce-mode was blocking. Endpoint already existed atsrc/app/api/csp-report/route.ts(edge runtime, 4KB cap, format-only console.error β Vercel Runtime Logs) but wasn't wired from CSP. Plus NEW arc-guardscripts/check-csp-report-only.mjs(sister of cannagent v5.6925 + sureel + glw + scc) β pins the header + report-uri + endpoint + default-src baseline against regression. Wired into.githooks/pre-pushbuild-gates (17/17 β 18/18). Doug grepsvercel logs | grep csp-violationto see what's being blocked. tsc clean.
Fixed
- π‘οΈ NEW X-Robots-Tag noindex, nofollow on /admin/:path* β defense-in-depth on top of robots.txt /admin disallow. The 46 patient/clinic admin pages are cookie-gated but URL leaks (Slack/Twitter/email shares) could let Google index admin URLs with 'No description because of robots.txt' even though robots.txt already disallows /admin/. Header-level noindex closes the indexing-without-crawling gap. Especially important for GW since admin surfaces handle PHI flows. Sister cannagent v5.6585 same-day port. tsc clean.
Fixed
- π‘οΈ Pre-push build-gates chain extended from 10/10 to 17/17 β 7 arc-guards that exist as scripts + are wired into
pnpm check:all(manual run) but were bypassed by.githooks/pre-push(the actual push gate). Pre-T117 push could land code that violated og-completeness / duplicate-brand-title / og-image-shape / per-route-og-image / title-length-html / description-length-html / button-type rules β caught only when someone manually ranpnpm check:all. Now ALL 17 gates run on every push (matching package.json check:all chain). Bonus fix:check-og-completeness.mjswas hitting a false-positive onsrc/lib/changelog.ts:501where a changelog entry's PROSE includes the literal substringopenGraph: {(describing past T19/T20 fixes) β gate's regex couldn't tell metadata-block from prose. Addedsrc/lib/changelog.tsto the EXEMPT set (sister ofsrc/app/layout.tsxwhich is also exempt for similar reasons). Round-5 2nd ship β completes the GW arc-guard discipline from round-2-3-4 (originally 7 marketing-site arc-guards plus 3 GW-specific helpers totaling 10; now all 17 are pre-push gated). tsc clean.
Added
- π‘οΈ NEW arc-guard
scripts/check-article-physician-ld-completeness.mjsβ pins T87 v2.97.M0 (Article|MedicalWebPage@id+publisher.@id) and T88 v2.97.N0 (Physician@id+worksFor.@id) against regression. Validates 14 required Google-structured-data fields across 2 SoT helpers (buildArticleLd8 fields +buildPhysicianLd6 fields) PLUS 2 nested-@id linkage checks (publisher block must contain @id ref to homepage Organization; worksFor block same). Heuristic brace-depth body extraction matches T84 + T103 pattern. Catch-rate verified via injection: stripped publisher.@id line β guard fired with exact missing-field message + T87/T88 changelog reference; reverted clean. Wired intopnpm check:article-physician-ld(manual) AND.githooks/pre-pushbuild-gates chain (now **10/10** β was 9/9). Round-5 first ship β extends T84 medical-clinic + T103 breadcrumb arc-guard pattern to the remaining round-3 entity-graph @id work. **3 GW-specific JSON-LD arc-guards now**: medical-clinic (T84), breadcrumb (T103), article+physician (T116). All 3 SoT JSON-LD helpers from round-3 entity-graph @id arc are now write-protected against regression. tsc clean.
Fixed
- π§ Spam-trigger words removed from 2 patient renewal email subject lines. Pre-fix: (1)
Urgent: 7 days left on your authorizationβ "Urgent:" prefix is a known mild spam trigger per Mailchimp + SendGrid spam-filter studies (raises score ~2-3 points). (2)Final notice β your authorization expires ${expiryDate}β "Final notice" is a stronger spam trigger word (collection-letter language). These are time-sensitive renewal reminders to active patients β if Gmail spam-filters them, the 7-day-warning email NEVER reaches the patient and they lose their authorization. Critical deliverability concern especially in HIPAA-aware patient cohort. Fix: (1)Urgent: 7 days left on your authorizationβ7 days until your authorization expires(urgency preserved in body, removed from subject). (2)Final notice β your authorization expires ${expiryDate}βYour authorization expires ${expiryDate} β last week to renew(clearer + non-trigger). Body copy unchanged. Audited all 25 GW subject lines + glw + scc subject lines β only these 2 triggered the spam-trigger filter. Round-4 third ship continues the round-3 email-quality arc (T73 a11y β T75/T77 preheaders β T78/T79 List-Unsubscribe + multipart β T104 spam-trigger words). tsc clean.
Added
- π‘οΈ NEW arc-guard
scripts/check-breadcrumb-ld-id.mjsβ pins T101 v2.97.O0 fix (@idfield added tobuildBreadcrumbLd()SoT helper) against future regression. Heuristic check: locate the function via brace-depth tracking fromreturn {, scan body for"@id":literal, fail with detailed-context message when missing. Catch-rate verified via injection test: temporarily deleted the @id line β guard fired with exit 1 + clear remediation instructions; reverted cleanly. Wired into BOTHpnpm check:breadcrumb-ld-id(manual) AND.githooks/pre-pushbuild-gates chain (now 9/9 β was 8/8). Same pattern as T84check-medical-clinic-ld-completeness.mjsβ ship-the-fix β ship-the-arc-guard. Single-function-validator scope (vs T84's 2-function validator) since BreadcrumbList has only one SoT helper on GW. Round-4 second ship (T101 was first); round-3 close pattern of arc-guards-after-arc-fixes continues.
Fixed
- π BreadcrumbList @id linking on
buildBreadcrumbLd()SoT helper β closes the entity-graph @id linking arc across THE WHOLE 6-site stack. Pre-fix every BreadcrumbList on GW (telehealth/locations/learn/faq/about/privacy/terms/refer/changelog/dispensaries/conditions etc β ~15 caller pages) was a dangling node without entity-level @id. Sibling Article|MedicalWebPage / MedicalClinic / ContactPage couldn't reference the breadcrumb via @id. Sister of glw + scc + cannagent + sureel + vrg T91-T98 same pattern. Single SoT helper edit emits@id: ${SITE_URL}${last.path}#breadcrumbderived from the last crumb's URL β all callers inherit automatically. Zero inline-BreadcrumbList emitters on GW, so the helper is the single point of fix. Caught by /loop tick 101 GW probe (deferred from T96 by Vercel WAF challenge β file-dump workaround bypassed pipe failure). T100 round-3 close noted GW BreadcrumbList probe was the only outstanding gap; T101 closes it. **First ship of round 4.** tsc clean.
Fixed
- π WebSite + Physician JSON-LD entity-graph @id linking β sister of T87 Article @id pattern. (1)
buildWebSiteLd()now emits@id: ${SITE_URL}/#website(sister of glw + scc which both already have this β GW was the lone outlier across the stack) +publisher.@id: ${SITE_URL}/#organizationlinking to homepage MedicalClinic. WebSite is the Google Knowledge Graph anchor entity, so this @id lets every other JSON-LD node on the homepage reference it cleanly. (2)buildPhysicianLd()(currently dead-code: no/providers/[slug]public page exists yet β admin UI at/admin/providersonly β but the helper is staged for future ship per roadmap) gets the same @id pattern preemptively:@id: ${SITE_URL}/providers/${slug}/#physician+worksFor.@id: ${SITE_URL}/#organization. **Methodology lesson re-confirmed**: T85 dead-code-helper class triggered AGAIN β buildPhysicianLd has no callers (verified viagrep -rn buildPhysicianLd src/appreturned zero matches) yet roadmap admin page lists "Per-page JSON-LD β Physician" as done. Memory pin candidate: 'helper exists in seo.ts != it's wired into a live page'. T87 (Article) β T88 (WebSite + Physician) closes the @id-on-non-MedicalClinic surface. tsc clean.
Fixed
- π Article|MedicalWebPage JSON-LD on /learn/[slug] β now emits
@id+publisher.@id. Pre-fixbuildArticleLd()insrc/lib/seo.tshadmainEntityOfPage.@idbut NO entity-level@id, so the article entity was a dangling node β couldn't be cleanly referenced from BreadcrumbList / FAQ / Speakable nodes. publisher was a bare Organization with name+url only (no @id), so Google's structured-data parser couldn't merge it into the homepage MedicalClinic entity graph that already lives at${SITE_URL}/#organization. Added (1)@id: ${SITE_URL}/learn/${slug}/#articlefor the article entity itself, (2)publisher.@id: ${SITE_URL}/#organizationto merge publisher β homepage MedicalClinic. Same pattern as T85 (per-location MedicalClinic parentOrganization @id linking) andbuildConditionWebPageLd()already on /conditions/[slug] (which had this pattern from v2.93.45). buildArticleLd verified live wired into /learn/[slug]/page.tsx (no dead-code-helper class like T85/T86). Round-3 SEO/structured-data hardening continues β T83 β T85 β T86 closed MedicalClinic surface; T87 starts closing the Article surface with the same @id/entity-graph methodology. tsc clean.
Fixed
- π 5 more pages emitting inline MedicalClinic JSON-LD now have the same
@id+email+priceRange+logo+imagefields T83 + T85 added to the SoT helpers. Pre-fix the live pages /telehealth, /telehealth/[city], /telehealth/[city]/[condition], /locations/[city]/[condition], and /pricing were emitting bespoke MedicalClinic schemas that diverged from the SoT β every one was missing the same Google-recommended fields. Sweep methodology: grep"@type": "MedicalClinic"acrosssrc/app, curl-probe each live URL to confirm missing fields (logo: NO,image: NO,email: NO,@id: NOβ all 5 affected), then add the 5 fields inline to each page (couldn't refactor to SoT helper without a per-page-customized variant β each page has uniquenamelikeGreen Wellness β ${city} Telehealth Β· ${condition}that the helpers don't take). ImportedEMAILfrom@/lib/constantson each file. Pages NOT affected: /locations (uses different schema shape) + /get-started (no MedicalClinic emitted). T83 β T85 β T86 closes the per-MedicalClinic logo/image gap across the whole GW app surface β every live MedicalClinic schema across all routes now has the required Google-structured-data fields. Round-3 SEO/structured-data hardening continues; methodology of curl-probe-after-deploy proven again across 3 ticks. tsc clean.
Fixed
- π T85 v2.97.J0 SELF-REGRESSION caught + fixed: edits to
buildLocationLd()insrc/lib/seo.tshad ZERO effect on the live/locations/[city]pages because the page emitted an INLINElocalBusinessJsonLdobject, never calling the SoT helper. Verified via curl + JSON.parse on https://greenwellness.org/locations/lynnwood post-T85: every logo/image/email/@id field wasnulldespite the SoT helper having them. Same dead-code-helper class as T48-T50 per-route OG images (SoT existed, callers didn't reach for it). Refactoredsrc/app/locations/[city]/page.tsxto callbuildLocationLd({ city, state, zip, address, name, phone, hours: parseHoursJson(loc.hours), slug: city })β single SoT call replaces 19 lines of inline object literal. Also deleted the localbuildOpeningHoursSpec()function (~10 lines) which was the inline schema's hours helper but is now dead code (the SoT helper has its ownbuildOpeningHoursSpecinternally). Net: -29 lines page code; live MedicalClinic schema now matches what arc-guard validates. Methodology lesson: arc-guard validates the SoT helper, not the rendered HTML β also need post-deploy curl to confirm the SoT is actually wired into the page tree. Same lesson as T81 (cascade overwrite caught via prod-headers-first re-curl). tsc clean.
Added
- π Per-location MedicalClinic JSON-LD now also emits
logo+image+email+@id(sister of T83 v2.97.H0 same-fix on the homepage MedicalClinic). Pre-fixbuildLocationLd()insrc/lib/seo.tswas missing the same Google-recommended structured-data fields that T83 fixed onbuildMedicalBusinessLd()β every per-location page (/locations/[city]) had identical Knowledge Panel + voice-search + rich-result surfacing degradation. Added (1)@idIRI for per-location entity-graph linking, (2)email(mirror of homepage), (3)logo: ${SITE_URL}/icon(square 180Γ180), (4)image: ${SITE_URL}/opengraph-image(1200Γ630). Also upgradedparentOrganizationreference to include@id: ${SITE_URL}/#organizationso Google can merge the per-location MedicalClinic into the homepage organization entity (entity-graph correctness β pre-fix it was a dangling ref-by-name only). Arc-guard EXTENDED:scripts/check-medical-clinic-ld-completeness.mjsnow validates BOTHbuildMedicalBusinessLd()(12 fields) ANDbuildLocationLd()(10 fields β no email/availableService since those live on the parent org) β total 23 fields validated. Catch-rate verified: when arc-guard runs against post-fix source, reportsall 23 required fields present across 2 MedicalClinic emitters. Round-3 SEO/structured-data hardening β completing the cross-function audit T83 began.
Added
- π‘οΈ NEW arc-guard
scripts/check-medical-clinic-ld-completeness.mjsβ pins T83 v2.97.H0 fix (logo + image fields on MedicalClinic) against future regression. Heuristic check: locatesbuildMedicalBusinessLd()insrc/lib/seo.tsvia brace-depth tracking, scans the function body for 12 required Google-recommended structured-data fields (@id+name+description+url+telephone+email+priceRange+logo+image+medicalSpecialty+areaServed+availableService), fails the build with a per-field missing list when any are absent. Verified catch-rate via injection test: temporarily deletedlogo:line β guard fired with 'missing: logo' + exit 1; reverted cleanly. Wired into BOTHpnpm check:medical-clinic-ld(manual) AND.githooks/pre-pushbuild-gates chain (now 8/8 β was 7/7) so any regression on the SoT function is caught at push-time before it can reach prod. Sister of glw + scc og-completeness/og-image-shape/title-length-html arc-guards. Pattern: ship the fix β ship the arc-guard. Round-3 SEO/structured-data hardening continues.
Added
- π MedicalClinic JSON-LD now emits
logo+imageβ recommended by Google's structured-data guidelines for medical-business rich results, Knowledge Panel, and voice-search surfacing. Pre-fix the schema insrc/lib/seo.ts buildMedicalBusinessLd()had neither field; SERPs fell back to a screenshot of the homepage as the visual representation, and the Knowledge Panel had no logo (worse brand recall on patient searches like 'medical marijuana clinic Seattle' or 'WA telehealth medical card'). Addedlogo: ${SITE_URL}/icon(180Γ180 PNG, square ratio β ideal logo dimensions per Google) andimage: ${SITE_URL}/opengraph-image(1200Γ630, representative photo for the entity). Both routes already serve correctly + are 24hr edge-cached.sameAs(social-profile disambiguation) deferred β GW doesn't have a hardcoded GBP/Facebook URL on file yet; can add later when those URLs are confirmed. Caught 2026-05-10 by /loop tick 83 JSON-LD validation sweep across the 6-site stack β GW MedicalClinic was the lone outlier (glw + scc + cannagent + sureel all clean). Especially important for HIPAA-aware patient-facing surface where SERP first-impression directly affects appointment-booking funnel.
Added
- π§ Plain-text email alternative auto-generated from HTML β every outbound email is now multipart/alternative instead of single-part HTML. Pre-fix
src/lib/email.tsprovider sends only includedHtmlBody(Postmark) /html(Resend) β single-part HTML emails are a deliverability red flag (Gmail spam filter + Outlook SmartScreen + most enterprise filters all rank multipart higher), and Apple Watch / smart-reply / plain-text-preferring users got nothing readable. AddedhtmlToText(html)helper that strips style/script/head + display:none preheader divs, preserves URLs next to anchor text in parens (Sign in (https://greenwellness.org/portal/login)), converts block-end tags +to newlines, list-items to-prefixes, decodes 7 entities (&/</>/"/'/'/), collapses whitespace + caps newlines at 2. All 23 sendEmail callers benefit automatically (no per-template work). Verified output via Node test on a sample bookingConfirmation HTML β preserves headline + body + portal link + phone/email contact rows readably. Both PostmarkTextBodyand Resendtextfields populated; SES adapter is a stub so no change there. Especially relevant for HIPAA-aware patient emails where deliverability matters most (renewal reminders + booking confirmations + DOH-nudge cohort). Round-3 email-quality arc continues from T78 (List-Unsubscribe HTTP headers on glw + scc) β T79 closes the equivalent gap on GW's transactional + marketing flows. tsc clean.
Added
- π§ Preheaders wired on 5 more time-sensitive patient emails: (1) noShowEmail β
Life happens. Telehealth renewal takes 15 minutes β let's pick a timeβ¦(2) renewalReminderEmail β per-stage preheader threaded through the urgencyMap (60d/30d/14d/7d each get a distinct line that surfaces expiry date + lead-time framing). (3) renewalEscalationEmail β${expiryDate} is your last day. After that, you'd restart as a new patient β bookβ¦(4) reEngagementEmail (90-day) βAuthorization still active through ${expiry}. Book your renewal whenever you're ready.(5) winBackEmail (post-expiry) βReturning patient telehealth β \$${PRICING.RETURNING_TELEHEALTH}, ~15 min, same-week. New auth runs through ${newExpiryDate}.Each preheader extends (doesn't echo) the subject line β the inbox-preview text becomes a second hook that increases open rate on time-sensitive renewal sequences. T77 continues the round-3 email-quality arc started at T73 (role=presentation), T74 (preheader infra), T75 (3 highest-value), T76 (link underlines). Caught 2026-05-10 by /loop tick 77 β false-positive privacy-link probe pivoted into completing the preheader sweep on the renewal cohort that drives the most patient open-rate variance.
Fixed
- βΏ Email link underlines (WCAG 1.4.1 β Use of Color). Pre-fix all 22 inline text links in
src/lib/emails.tsusedstyle="color:#2d6a4f"(sage green) WITHOUTtext-decoration:underline. Per WCAG 1.4.1, color cannot be the SOLE means of distinguishing links from regular text β low-vision and color-blind patients couldn't easily tell what was clickable. Addedtext-decoration:underlineto every inline text link (regex sweep, skipped buttons which have bg-color + display:inline-block as the cue). Sister glw + scc emails already had this pattern (verified via grep). Especially important for HIPAA-aware GW patient emails where vision-impaired patients managing care need clear interaction affordances. T76 closes the GW email-a11y arc started at T73.
Added
- π§ Email preheader infrastructure + 3 highest-value templates wired. T75 follow-up to T74 (glw + scc preheader sweep).
shell()helper got a new optional 3rd parameterpreheaderβ backwards-compat preserved (existing 2-arg callers unchanged). Hidden viadisplay:none;font-size:0;max-height:0;mso-hide:all;so it doesn't render in body but Gmail/Apple Mail/Outlook all surface it as the inbox-preview line. Wired on the 3 highest-value patient-facing templates: (1) bookingConfirmation β "${apptDate} β what to bring + check-in link inside." (2) reminderEmail β "Reminder: your Green Wellness appointment ${urgency}. Tap for the join link + checklist." (3) authIssued β "Your authorization is issued. You can shop at any WA dispensary today." Remaining 6 templates (no-show, renewal-30d/14d/7d, cohort-3month, win-back, rescheduled, cancelled, payment-receipt, etc.) keep working unchanged via backwards-compat β preheader can be threaded later as content matures. Customer-UX win on highest-visibility inbox surface. Sister of glw v18.605 + scc v13.6305 (3 templates each, simpler structure had/directly). Caught 2026-05-10 by /loop tick 75 round-3 email-quality continuation.
Fixed
- βΏ Email a11y β
role="presentation"added to 8 layouttags in
src/lib/emails.ts(patient-facing transactional templates: appointment confirmation, reminder, intake-link, password-reset, etc). Pre-fix every email usedfor layout (correct β flexbox/grid don't render reliably across email clients) WITHOUT the
role="presentation"ARIA hint. Screen readers (and email-client assistive-tech overlays) announced these layout tables as DATA tables β "Table with 1 row, 1 column" β confusing the patient's experience. Especially relevant for HIPAA-aware patient surfaces where vision-impaired patients managing their care need clean email-body audio rendering.role="presentation"tells assistive tech "this is layout scaffolding, ignore semantics" so the prose flows naturally. WCAG 1.3.1 (info-and-relationships) hardening. Sister glw v18.505 (7 fixes) + scc v13.6205 (7 fixes) β round-3 dimension shift after T67-T72 saturation. Pure additive; no visual change in any email client.v2.97.B02026-05-10ProductionAdded
- π‘οΈ NEW arc-guard
scripts/check-button-type.mjsβ sister glw v18.405 + scc v13.6105 cross-stack port. Pins T69 button-type sweep (313 buttons / 101 files) against future regression. Walkssrc/app/+src/components/for everyopen tag. Fails build whenonClick=handler is present AND explicittype=attribute is absent β strong signal the button is JS-handled (not form submit). Bareallowed. 0/0 on GW post-T69. Wired intopnpm check:button-typeANDpnpm check:allbuild-gate. Caught 2026-05-10 by /loop tick 70.
v2.97.A02026-05-10ProductionFixed
- βΏ
sweep across 80 files (211 buttons). Pre-fixwithout explicittype=defaulted totype="submit"per HTML spec. INSIDE patient-portal forms (login, change-password, reset-password, my-appointments, patient-edit, admin-providers, admin-locations, admin-users) pressing Enter while focused on any input could trigger an UNRELATED button (e.g. delete, remove, back) instead of the form's actual submit. Real risk for HIPAA-aware patient surfaces where accidental click could leak/delete data. Python regex insertedtype="button"only whereonClick=was present ANDtype=was absent (preserves intentional submit buttons). Sister glw v18.305 (50 buttons) + scc v13.6005 (52 buttons). Caught 2026-05-10 by /loop tick 69 button-type-default audit. WCAG 2.1.1 hardening + accidental-submit prevention.
v2.97.902026-05-10ProductionAdded
- π Robots meta β added Google SERP-display directives
max-snippet:-1,max-image-preview:large,max-video-preview:-1to root layout robots.googleBot block. Pre-fix onlyindex, followwas declared β Google used conservative defaults: shorter snippets (~160c cap), smaller image previews, no video preview. With these directives, Google can render full-length snippets in SERPs (matters for GW patient-condition pages where the snippet is the customer's first impression of our copy on "is X covered" / "qualifying for medical marijuana with Y" searches), large image previews, and full video previews. Pure additive SERP-visibility win β no functional change, no risk. Sister glw v18.205 + scc v13.5905 same-fix. Caught 2026-05-10 by /loop tick 65 robots-meta detail audit.
v2.97.802026-05-10ProductionAdded
- π‘οΈ NEW arc-guard
scripts/check-description-length-html.mjsβ sister glw v18.105 + scc v13.5805 cross-stack port. Pins T6 + T62 fixes (description-length HTML-rendered cap) against future regression. Walkssrc/app/, finds every TOP-LEVELmetadata.description:declaration, computes HTML-escaped rendered length (&+4,'+5,"+5,<+3,>+3), fails if > 160 chars. Same brace + bracket-depth aware reverse-walk pattern as T63 title arc-guard β only flags metadata-export descriptions, skips array-of-object data structures + dynamic template literals. Pairs with T63 title-length arc-guard to close the SERP-truncation arc on BOTH title + description sides. 0/0 on GW currently. Wired intopnpm check:description-length-html(manual) ANDpnpm check:all(chained build-gate). Caught 2026-05-10 by /loop tick 64.
v2.97.702026-05-10ProductionAdded
- π‘οΈ NEW arc-guard
scripts/check-title-length-html.mjsβ sister glw v18.005 + scc v13.5705 cross-stack port. Pins T5 + T24 + T25 + T61 fixes (title-length HTML-rendered cap) against future regression. Walkssrc/app/, finds every TOP-LEVELmetadata.title:declaration (string literal or{ absolute }form), computes HTML-escaped rendered length (&β&+4 chars,'β'+5,"β"+5,<β<+3,>β>+3) PLUS layouttitle.templatesuffix when applicable, fails build if rendered length > 60 chars. Brace-depth-aware reverse-walk + bracket-depth tracking only flags titles that are immediate children of the exportedmetadataobject β skips array-of-object data structures (training step titles, FAQ items, breadcrumb chains, navigation links) which don't render as. Skips template literals with ${expr}interpolation (dynamic β can't measure statically). 0/0 on GW currently. Wired intopnpm check:title-length-html(manual) ANDpnpm check:all(chained build-gate). Cross-stack symmetric. Verified catch-rate via injection test on glw /blog. Caught 2026-05-10 by /loop tick 63.
v2.97.602026-05-10ProductionFixed
- βΏ Patient-intake form
autoComplete="off"on 2 medical-context inputs. Pre-fix /intake/[token] hadfor **allergies** (line 251) and **treating physician** (line 365) with NO autoComplete attr β browser autofill could suggest random text from past form contexts (or the patient's own name into the treating-physician slot). Especially important for HIPAA-aware patient surfaces where data integrity matters: an allergies field auto-filled with stale text from a prior browsing session could lead to incorrect medical records.autoComplete="off"opts these fields out explicitly. Sister glw v17.805 + scc v13.5405 T58+T59 form-autocomplete sweep applied to GW. Caught 2026-05-10 by /loop tick 60 patient-intake form audit.
v2.97.502026-05-10ProductionAdded
- βΏ WCAG 2.3.3 β
@media (prefers-reduced-motion: reduce)global override added tosrc/app/globals.css. Pre-fix admin-portal loaders (Loader2 spinners), waitlist skeleton (animate-pulse), previsit-form submitting indicator, and ~dozen other surfaces usedanimate-pulse/animate-spin/animate-bounceTailwind classes without amotion-reduce:variant. Browser users with vestibular conditions / migraine triggers / motion sensitivity saw all these animations regardless of their OS-level Reduce Motion preference. Especially relevant for HIPAA-aware patient surfaces β chronic-condition patients using assistive tech may need motion-reduce. Global override slows everything to 0.01ms (effectively instant but keeps final keyframe state) when the OS preference is set. Sister glw + scc both already had this pattern in their globals.css; GW was the lone outlier across the 3 web surfaces. Caught 2026-05-10 by /loop tick 57 cross-stack prefers-reduced-motion audit.
v2.97.402026-05-10ProductionAdded
- π±
applicationName: "Green Wellness Medical"added to root layout metadata. Emitsβ used by Microsoft Edge + Windows Start menu when pinning the site as a tile / app shortcut, and by some browser plugins as the canonical app identifier. Pre-fix all 3 sites in the stack (glw + scc + GW) were missing this meta tag (verified via curl audit). Pure additive β no existing functionality affected. Sister glw v17.605 + scc v13.5205 same-fix.
v2.97.302026-05-10ProductionAdded
- π og:locale = en_US in root layout openGraph block. Facebook/LinkedIn unfurlers + Google use this property for region-aware share-card rendering. GW was a lone outlier (along with vrg) missing this property across the 6-site stack β 4/6 had it (glw, scc, cannagent, sureel). Sister vrg v0.14.0 same-class fix. Caught 2026-05-10 by /loop cross-stack og:locale sweep.
v2.97.302026-05-10ProductionAdded
- π‘οΈ Permissions-Policy hardening β 9 directives added to lock down browser APIs the patient-portal + marketing site never invokes: payment / usb / serial / bluetooth / midi / xr-spatial-tracking / magnetometer / accelerometer / gyroscope. All set to empty allowlist
()= disabled for self + iframes. Defense-in-depth β blocks any future vendor JS from silently invoking these. Especially important for HIPAA-aware GW where third-party JS surface is already minimized; this closes browser-API attack surface (a malicious script that somehow loads couldn't read device sensors / hardware peripherals / payment-request modal even if it tried). Pure additive β no existing functionality uses any of these APIs. interest-cohort=() opt-out (FLoC/Topics) preserved. Sister glw v17.305 + scc v13.5005 same-fix. Caught 2026-05-10 by /loop tick 52 cross-stack Permissions-Policy hardening audit.
v2.97.202026-05-10ProductionAdded
- π‘οΈ NEW arc-guard
scripts/check-per-route-og-image.mjsβ sister glw v17.205 + scc v13.4905 cross-stack port. Pins T48 + T49 fixes against future regression. Walkssrc/app/, finds every directory with a co-locatedopengraph-image.tsxfile convention, checks the sibling page.tsx foropenGraph.images: [...]arrays containing known dead-code patterns:DEFAULT_OG_IMAGEliteral,ref, or.logoUrl "/opengraph-image"string literal (homepage path). The bug class: when present, these patterns OVERRIDE Next 16's per-route file convention, making the co-locatedopengraph-image.tsxdead code (every share-card on Twitter/Facebook/LinkedIn/iMessage renders the wrong image). 0/0 on GW currently (GW uses/api/og?title=β¦for OG generation, not file convention). Wired intopnpm check:per-route-og-image(manual) ANDpnpm check:all(chained build-gate). Cross-stack symmetric β identical script ports cleanly to all 3 repos. Caught 2026-05-10 by /loop tick 50.
v2.97.102026-05-10ProductionFixed
- π Explicit seoTitle on /learn/washington-medical-vs-recreational-dispensary-guide β sole remaining over-cap /learn page after v2.96.90 deployed (post-deploy exhaustive sweep showed 1/30+ over). deriveSeoTitle can shorten 'Washington State' β 'WA' but cannot strip the descriptive 'What's the Difference?' question β needs explicit author override. New seoTitle: 'Medical vs. Recreational Dispensaries in WA' (50 JS / 55 HTML). H1 keeps the full descriptive title for visitors. Closes the GW SSoT title cap arc β 0/30+ sampled /learn pages over cap on next deploy.
v2.96.902026-05-10ProductionFixed
- π deriveSeoTitle return-shortened-when-shorter (drop suffix-fit gate). Pre-fix checked
s.length + 17 <= 60(whole title-with-suffix budget), which returned null for any shortened title that would still need suffix-drop. With v2.96.10's buildPageMetadata auto-suffix-drop logic, the suffix is dropped when title would overflow β so deriveSeoTitle's job is just 'make it shorter than the original', let downstream handle suffix-fit. Caught by /loop GW post-v2.96.70 /learn re-sweep β 3 remaining /learn pages (PTSD-veterans-and-civilians, tax-savings, medical-vs-recreational-dispensary-guide) had derived titles 55-66 chars that would render perfectly with title.absolute but deriveSeoTitle was throwing them away. Closes the GW SSoT title arc that started v2.96.10. Sister of v2.96.70.
v2.96.802026-05-10ProductionAdded
- π
colorScheme: "light"added to viewport export. Pre-fixwas absent, so browsers' auto-dark-mode logic could re-tint form inputs, scrollbars, and default UA UI to dark on systems where the user prefers dark mode. GW patient-intake forms (and the entire patient-portal experience) are explicitly light-themed; auto-dark coerces input bg toward gray, breaking contrast + readability + accessibility on the patient flow. Declaring the supported scheme explicitly opts out of the auto-adjust. Sister glw v16.505 + scc v13.4205 same-fix triple. Caught 2026-05-10 by /loop tick 43 cross-stack color-scheme audit (curl-confirmed all 3 sites missing the meta).
v2.96.702026-05-10ProductionFixed
- π deriveSeoTitle now matches mid-string 'Washington State' too. Pre-fix only the trailing-
$regex worked, so titles like 'Renewing Your Washington State Medical Marijuana Authorization' (62 chars) didn't get shortened β fell through to article.title and rendered 62 chars (over Google 60-char SERP cap). Caught by /loop GW post-v2.96.30 /learn re-sweep β 7/30 sampled /learn pages still over cap. Now matches with\bword-boundary anywhere in the string, plus order more-specific (' in Washington State' β ' in WA') before bare (' Washington State' β ' WA'). Sister of v2.96.10 SSoT auto-suffix-drop arc.
v2.96.602026-05-10ProductionAdded
- π±
formatDetection: { telephone, date, address, email: false }added to root layout metadata. iOS Safari auto-formats numeric strings (zip codes, prices, dates, addresses, dollar amounts, RCW statute numbers like69.51A) as tap-callable / tap-mail / tap-date by default β long-form medical content becomes peppered with accidental tap-targets. Especially important for HIPAA-aware GW: a customer reading about Medical Marijuana for Chronic Pain shouldn't accidentally dial '69.51A' or '$190' or '2026-05-15' when trying to highlight content. We already use explicitfor the 1-888-885-9949 line in 3+ hits per page (verified via curl), so disabling auto-detection sitewide is pure UX cleanup with no loss of functionality. Sister glw v16.405 + scc v13.4105 same-tick triple. Caught 2026-05-10 by /loop tick 42 cross-stack format-detection audit (curl-confirmed all 3 sites missing the meta tag pre-fix). Pure additive.
v2.96.502026-05-10ProductionFixed
- π Second-level title trim in
buildPageMetadataβ when title.absolute is STILL over 60-char SERP cap (rare edge case from very long condition + city combos like "MMJ Card for ALS (Lou Gehrig's Disease) Β· Bellingham, WA" = 61 chars HTML), drop parentheticals from the title. The alt-name annotation doesn't add SEO value beyond what the primary term conveys. Caught 2026-05-10 by /loop GW post-v2.96.10 sweep β 21/22 telehealth pages were resolved by v2.96.10's auto-suffix-drop, 1 remaining (Bellingham + ALS) needed this second-level handling. Sister of glw v15.805 + scc v13.2105 + scc v13.3805 parenthetical-drop pattern.
v2.96.402026-05-10ProductionFixed
- π€
/llms.txtContent-Typetext/plainβtext/markdown. The llms.txt spec (https://llmstxt.org/) requires markdown semantics β heading hierarchy, bullet lists, hyperlinks β and the body emitted from this route uses all three. AI crawlers (ChatGPT, Claude, Perplexity, Grok) that fetch /llms.txt as authoritative brand context prefertext/markdownfor proper structure parsing; undertext/plainsome crawlers strip the markdown semantics and ingest the body as flat prose, losing the structural hints (e.g.## Servicesstops being a section header). Sister glw + scc both servetext/markdownalready; GW was the lone outlier across the 6-site stack. Caught 2026-05-10 by /loop tick 41 cross-stack llms.txt MIME audit (curl-verified differences across the stack). Pure 1-line header fix; body unchanged.
v2.96.302026-05-10ProductionFixed
- π /learn/[slug] generateMetadata now entity-aware + auto-title.absolute when too long (sister of v2.96.10 buildPageMetadata SSoT fix). /learn/[slug] bypasses buildPageMetadata for article-specific seoTitle/seoDescription handling, so the same fix needed to ship there too. Pre-fix /loop GW exhaustive sweep round-5 caught 7/15 sampled /learn pages over cap (titles 69-100, descs 163-168 β apostrophes in 'Crohn's', 'Parkinson's' inflated
'β'; long-title articles fell through deriveSeoTitle to article.title which then hit template suffix). Now: entity-aware desc trim with iterative-shorten + auto-suffix-drop title when escaped length exceeds 60. Memory: feedback_html_escape_inflates_meta_description_length.
v2.96.102026-05-10ProductionFixed
- π Cross-stack title cap fix in
buildPageMetadataβ auto-switch totitle.absolutewhen${title} | Green Wellnessrendered HTML would exceed Google ~60-char SERP cap. Pre-fix EVERY page in /telehealth/[city]/[condition] (244 pages, 22/25 sampled were over) + /locations/[city]/[condition] matrix (13/25 over) + many /learn pages had 61-103 char titles because the metaTitle template (e.g.Telehealth MMJ Card for ALS (Lou Gehrig's Disease) Β· Seattle, WA) plus the| Green Wellnesstemplate suffix overshot every time. Caught 2026-05-10 by /loop GW exhaustive title sweep β 49 over-cap titles in 75 sampled pages. Single-source-of-truth fix in buildPageMetadata means every caller benefits without per-page changes. Also entity-aware (apostrophe in 'Lou Gehrig's' inflates'β'). Memory: feedback_html_escape_inflates_meta_description_length. - π Drop
Telehealthprefix from/telehealth/[city]/[condition]metaTitle template (wasTelehealth MMJ Card for ${condition} Β· ${city}, WA, nowMMJ Card for ${condition} Β· ${city}, WA). Saves ~11 chars + lets the condition.name survive Google's 60-char SERP cap when title.absolute kicks in via buildPageMetadata's auto-suffix-drop. URL path /telehealth/[city]/[condition] already conveys the telehealth context.
v2.95.802026-05-10ProductionAdded
- π‘οΈ
X-Robots-Tag: noindex, nofollowheader on /api/:path* responses. Defense-in-depth on top of robots.txt's existingDisallow: /api/. robots.txt blocks crawlers from FETCHING /api URLs, but if an API URL gets shared externally (Slack/Twitter unfurls, accidental copy-paste in tweets, email links), Google may still INDEX the URL without crawling it β SERP shows the bare URL with "No description available because of robots.txt." That's worst-of-both-worlds: SERP exposure of internal endpoint name + zero description. Header at response level says "even if you DID get here, don't index this." Especially important for HIPAA-aware GW where /api/intake, /api/admin/calendar, /api/admin/patients/export etc. should never appear in SERPs even by URL alone (despite already being auth-gated for the body, the URL itself can still index). Verified missing across all 6 stack sites via curl β none currently set this header. Sister glw v16.205 + scc v13.3605 same-fix; cannagent + sureel + vrg follow-up pending. Pure additive β no behavior change, no customer-facing effect, just SERP-hygiene defense. Caught 2026-05-10 by /loop tick 38 prod-headers-first methodology audit (lesson from T36 retraction: probe prod headers FIRST, code-search SECOND).
v2.95.702026-05-10ProductionChanged
- π± PWA
display: browserβstandalone+ addedscope/orientation/categories. Pre-fix when patients tapped "Add to Home Screen" + launched, the PWA opened in a regular Safari/Chrome tab WITH the URL bar + tab strip β defeating the purpose of the home-screen install. For a HIPAA-aware patient surface (telehealth + intake + appointment management) the app-like fullscreen experience is materially better. Sister glw + scc + cannagent + sureel + vrg all already usestandalone; GW was the lone outlier across the 6-site stack. Also added explicitscope: "/"(was implicit),orientation: "portrait"(matches mobile-first design), andcategories: ["medical", "health", "lifestyle"](helps app-store + chrome-os categorization). Note:display: browserwas an intentional original choice when GW was email-deep-link-only (every email link wanted to land in the user's browser context with full back-button history); now that /my-appointments + /referral are real patient destinations the standalone PWA UX is correct. Caught 2026-05-10 by /loop tick 35 cross-stack manifest display-mode audit. Sister glw v15.605 + scc v13.3105 same-tick (both retarget theOrder for PickupPWA shortcut from /menu broken-target to /order real-cart-target).
v2.95.602026-05-10ProductionRemoved
- π©Ή Dropped
WebSite.potentialAction.SearchActionfrom JSON-LD β pre-fix declaredurlTemplate: ${SITE_URL}/?q={search_term_string}claiming GW homepage was a search endpoint, but no page on the GW marketing site reads?q=searchParams (no site-search exists). Google's Sitelinks Searchbox feature would have rendered a search box under the GW SERP listing and sent customer queries to a URL the homepage silently ignores β false promise. Sister glw v15.305 + scc v13.2905 RETARGETED their SearchAction at/order?q=(real search surface β OrderMenu reads the query); GW has no equivalent endpoint so we drop the action entirely. Re-add when a real site-search ships. Caught 2026-05-10 by /loop tick 33 SearchAction-target-actually-works audit.
v2.95.502026-05-10ProductionFixed
- π° /learn/[slug] declared
og:type=articlebut emitted ZERO article:* meta tags. Pre-fix every learn-article share card on Facebook + LinkedIn rendered as a genericog:type=articleblock without thearticle:published_time(date label),article:section(category pill above title), orarticle:tag(taxonomic clustering for re-shares) sibling tags. Per OGP spec these are the highest-consumed article-meta fields by share-card unfurlers. Sister glw v15.205 + scc v13.2805 (which add section + tags to /blog/[slug] β those repos already had publishedTime but were missing section + tags). GW was the lone outlier missing publishedTime entirely. Articles already carrypublishedAt(ISO 8601 string) +categoryfields so the fix is a 3-line metadata addition. Bonus: also addedalt: article.titleto the og:image (was previously omitted). Caught 2026-05-10 by /loop tick 32 OGP article-spec completeness sweep.
v2.95.402026-05-10ProductionAdded
- π‘οΈ NEW arc-guard
scripts/check-og-image-shape.mjsβ sister glw v15.105 + scc v13.2705 cross-stack port. Pins T29 (og:image shape sweep) + T30 (twitter.images alt via buildPageMetadata) against future regression. Walkssrc/app/+src/lib/, finds everyimages: [...]array inside anopenGraph: {}ortwitter: {}block (brace-depth-aware reverse-walk skips arrays in unrelated scopes), fails the build if any element is a literal string or template literal. Bare identifiers (e.g.DEFAULT_OG_IMAGE,OG_IMAGE_URL) are trusted β agents already reach for the SoT const, and identifier-typed-as-URL is rare. Sister of v2.94.55 og-completeness + v2.95.20 duplicate-brand-title arc-guards. Wired intopnpm check:og-image-shape(manual) ANDpnpm check:all(chained build-gate). 0/0 across 404 files post-fix. Cross-stack symmetric script β same file ported cleanly to all 3 repos. /loop tick 31.
v2.95.302026-05-10ProductionFixed
- πΌοΈ Twitter share-card image alt missing across every page using
buildPageMetadataSoT helper. Pre-fixsrc/lib/seo.ts:587settwitter.images: [ogUrl](string) β Next 16 emits ONLYfor string form, OMITTING. Twitter Cards spec recommends image alt for screen-reader accessibility (LinkedIn's accessible-mode + iMessage VoiceOver also read this attribute). Sister of glw v15.005 + scc v13.2605 og:image shape sweep β same string-form bug class on thetwitter.imagesfield instead ofopenGraph.images. The OG block already had alt (added in earlier sweep) but Twitter dropped it. Fix: changed to object-form[{ url: ogUrl, alt: input.title }]in the helper (cascades to every consumer page) + same fix on/get-started/page.tsx:61which doesn't use the helper. /loop tick 30 dimension-shift caught it after T29 closed the openGraph.images shape on glw + scc.
v2.95.202026-05-10ProductionAdded
- π‘οΈ NEW arc-guard
scripts/check-duplicate-brand-title.mjsβ sister of v2.94.55 og-completeness arc-guard, ports glw v14.905 + scc v13.2505 cross-stack. Pins T5 + T24 fixes against regression: when a page-levelmetadata.titlebody bakes in the brand (title: 'Leads β Green Wellness'), the layout'stitle.template = '%s | Green Wellness'appends the brand AGAIN, producing(brand twice in SERPs). Brace-depth-aware regex skips titles inside openGraph/twitter sub-blocks (where brand-in-title is intentional + harmless). Reads brand fromLeads β Green Wellness | Green Wellness src/lib/seo.ts SITE_NAME. First run caught 3 GW offenders the manual T24 sweep had missed:/admin/leads(admin surface βLeads β Green WellnessβLeads),/provider/training(Provider Training Guide β Green WellnessβProvider Training Guide), and/refer/[code]generateMetadata 404 fallback (Referral Code Β· Green WellnessβReferral Code). All 3 now render with brand exactly once via the template's append. Wired intopnpm check:duplicate-brand-title(manual) ANDpnpm check:all(chained build-gate). 0/0 across 350 files post-fix.
v2.95.102026-05-10ProductionAdded
- π‘οΈ Cross-Origin-Opener-Policy
same-originheader β isolates GW's browsing context from cross-origin windows opened via window.open(). Protects against Spectre/Meltdown side-channel attacks on shared memory + against cross-origin window.opener manipulation. Especially valuable for HIPAA-adjacent surfaces where patient browsing context shouldn't leak through opened popups. Safe vs Stripe + RingCentral integrations β those use iframes (not popups), and COOP only affects window.open() popup browsing contexts. CORP intentionally NOT set (marketing OG images need to be embeddable on share-card crawlers). Cross-stack port from cannagent SECURITY_HEADERS β GW + vrg + sureel were the lone 3 sites missing this header across the 6-site marketing stack. Caught 2026-05-10 by /loop cross-stack Cross-Origin-* header presence sweep. Sister vrg + sureel same-class fix shipping in parallel.
v2.94.952026-05-10ProductionFixed
- π SEO description trim β measure HTML-escaped length, not JS string length. Pre-fix v2.94.80 added auto-truncate to telehealth + locations city/condition lib helpers, but the threshold checked
description.length > 160which counts JS code units. Google measures the rendered HTML β long condition names with apostrophes + ampersands (Crohn's Disease & IBD, Parkinson's Disease, etc) inflated 9-12 chars under HTML escaping ('+&) so a 158-char JS-trimmed description came out as 167 chars in . v2.94.80 ship verified post-deploy still showed 7/25 telehealth pages over cap. Real fix: rebuilt the trim inbuildPageMetadata(single source-of-truth used by EVERY page) to escape-then-measure, iteratively shortening until the escaped length fits under 160. Most pages converge in 1-2 iterations because entity inflation is bounded by the count of escapable chars. Defense-in-depth iteration cap = 5. Sister truncate in the two lib helpers from v2.94.80 stays as a no-op safety net (will get cleaned up in a follow-up).
v2.94.802026-05-10ProductionFixed
- π SEO meta-description auto-truncation on /telehealth/[city]/[condition] (244 pages) + /locations/[city]/[condition] (city Γ condition matrix) β long condition names (Crohn's Disease, ALS, Epilepsy & Seizures, Parkinson's Disease, Multiple Sclerosis) pushed the templated description past Google's 160-char SERP cap. /loop telehealth random sample 2026-05-10 caught 8/25 over (Crohn's at 167, ALS at 163, Epilepsy at 162, Parkinson's at 163); locations sample caught 6/15 over (same condition class). Auto-truncate at 157+"β¦" enforced in both
lib/telehealth-condition-content.tsandlib/city-condition-content.ts. The condition + city name (SEO-critical) sit at the front of both templates so they always survive truncation; what gets cut is the trailing price line ($X renewals Β· $Y new patients) which is already on every page in 6+ visible places. Same auto-trunc pattern as v2.94.20 /learn fix.
v2.94.602026-05-10ProductionFixed
- π Duplicate
Green Wellnessbrand in 2 page titles β/aboutrendered asAbout Green Wellness | Green Wellness(brand twice, 41 chars) and/get-startedrendered asGet Started β Green Wellness Medical | Green Wellness(brand twice, 53 chars). Both pages baked the brand into theirtitle:body, then the layout'stitle.template = '%s | Green Wellness'appended it AGAIN. Sister of T5 v2.93.90 telehealth duplicate-suffix fix + the locations-content.ts class. Caught 2026-05-10 by /loop tick 24 GW duplicate-brand-title sweep β probed every sitemap URL's renderedfor Green Wellnesscount, flagged any with count>1. Fix:/aboutbody shortened toAbout Us(lets template append brand once, total 16 chars body + brand = clean);/get-startedswitched totitle.absolute(bypasses template, body =Get Started β Free Pre-Qualification | Green Wellness, 53 chars total, action-oriented + brand once). Net SERP-friendliness: both titles now β€53 chars, brand exactly once.
v2.94.552026-05-10ProductionAdded
- π‘οΈ NEW arc-guard
scripts/check-og-completeness.mjsβ pins all 7 required openGraph fields (type, locale, siteName, title, description, url, images) against future regression. Sister of glw v14.405 + scc v13.2005 ports. First run on GW caught 3 silent-drop offenders the manual T19-T21 sweeps had missed:src/app/get-started/page.tsx(locale),src/app/learn/[slug]/page.tsx(locale + siteName), and cruciallysrc/lib/seo.tsline 533 β thebuildPageMetadatahelper itself was missing locale. The helper is the source-of-truth for most page metadata on GW (every page that callsbuildPageMetadata({...})inherits its openGraph shape), so the missing locale silently dropped on dozens of /conditions/[slug], /learn, /telehealth/* etc pages. Fix: addlocale: "en_US"to the helper's openGraph block + the 2 page-level overrides. Wired intopnpm check:og-completeness(manual) ANDpnpm check:all(chained build-gate). Now 0/0 across 404 files.
v2.94.502026-05-10ProductionAdded
- π± PWA install meta β
appleWebAppblock added to root layout. Pre-fix GW emitted ZEROapple-mobile-web-app-*/mobile-web-app-capablemeta tags. iOS "Add to Home Screen" worked (apple-touch-icon + manifest already in place) but the launched app opened with full Safari chrome instead of standalone fullscreen, and the home-screen tile name fell back to the page title (often truncated mid-word on the springboard) instead of the short brand name. New block emitsmobile-web-app-capable=yes(the modern platform-neutral form),apple-mobile-web-app-status-bar-style=default, andapple-mobile-web-app-title=GreenWellness. 4/6 sites in the stack already had this; GW + vrg were missing. Sister vrg same-class fix shipping separately.
v2.94.402026-05-10ProductionFixed
- π¦ SEO+a11y β added
altexport tosrc/app/opengraph-image.tsxso og:image:alt is emitted on the homepage. Pre-fix the homepage rendered og:image / og:image:type / og:image:width / og:image:height but NOT og:image:alt β Twitter/Facebook crawlers fell back to the filename for screen-reader text. Per Next 16 file-convention docs,altexported from opengraph-image.tsx becomes the og:image:alt meta tag (overriding any layout.tsx openGraph.images[].alt). 5/6 sites in the stack already emit alt; GW alone was missing it because the file convention takes precedence over layout.tsx openGraph.images. Caught by /loop cross-stack OG image dimensions+alt audit 2026-05-10.
v2.94.302026-05-10ProductionFixed
- βΏ WCAG 2.4.1 β added skip-to-content link to root layout. Pre-fix keyboard users had no way to jump past nav + announcement banner; tabbing through the entire header chrome to reach page content. sr-only by default; focus reveals an emerald pill at top-left. Target #main-content matches a wrapper div added around
{children}. Sister cannagent v3.297 same WCAG fix. Caught by /loop cross-stack skip-link audit 2026-05-10.
v2.94.202026-05-10ProductionFixed
- π SEO meta-description auto-truncation on /learn/[slug] β sister of v2.94.05 title trim. /learn page bypasses buildPageMetadata which would auto-truncate at 157+"β¦". 10+ articles serving 162-192 char descriptions (over Google ~160 mobile SERP cap). Patient SERP queries truncated mid-sentence with "β¦". Added inline trunc: when description >160, slice to 157 + "β¦". Closes the meta arc started in v2.94.05.
v2.94.102026-05-10ProductionFixed
- π¦ Twitter/X share-card title bug. /loop tick 8 cross-stack og:title vs twitter:title comparison flagged GW /learn/[slug] (and other pages overriding
openGraph.titleviabuildPageMetadata) all showing the homepage TITLE constant on Twitter share cards instead of the page-specific title. Pre-fix layout hard-codedtwitter.title = TITLE+twitter.description = DESCRIPTIONand child pages overrodeopenGraphbut nevertwitter. Example:/learn/medical-marijuana-chronic-pain-washington-staterendered og:title="Medical Marijuana for Chronic Pain in Washington State | Green Wellness" but twitter:title="Green Wellness β Washington Medical Marijuana Evaluations". Fix: droptwitter.title+twitter.descriptionfrom layout. Per Twitter Cards spec + Next 16 metadata cascade, Twitter's crawler falls back to og:title + og:description when twitter:title isn't emitted. Now every page's Twitter card matches its OG card. Sister glw v12.905 + scc v13.605 same-class fix.
v2.94.052026-05-10ProductionFixed
- π SEO title-length sweep on /learn/[slug] β sister of v2.93.95 /conditions fix. ~30 of 34 articles had titles 65-90 chars after
| Green Wellnesstemplate suffix appended (e.g. "Can Washington State Medical Marijuana Patients Grow Their Own Cannabis? | Green Wellness" = 89 chars). Patient queries truncated mid-word in SERPs. Added optionalseoTitle+seoDescriptionfields to Article type + NEWderiveSeoTitle()helper that auto-shortens common patterns (Medical Marijuana β MMJ, Washington State β WA, drop trailing parens)./learn/[slug]page resolves: explicit seoTitle ?? derived ?? raw title. og:title still uses long-form (social cards have more room). One-helper ship fixes ALL 34 articles automatically β no per-article seoTitle population required, just the smart derive. Sister cannagent v3.304-v3.306 + glw v11.905 + scc v13.005 + GW v2.93.95 absolute/short-title pattern arc.
v2.93.952026-05-10ProductionFixed
- π SEO title-length sweep on /conditions β pre-fix 11 condition pages had titles 73-90 chars (e.g. "Medical Marijuana Evaluation for Multiple Sclerosis in Washington State | Green Wellness" = 88 chars), well over Google ~60-char SERP cap. Patient queries truncated mid-word in SERPs. Added optional
seoTitlefield to ConditionContent type; populated for all 11 conditions with shorter β€45-char body ("MMJ Card for Chronic Pain in WA" pattern)./conditions/[slug]falls back toheadlinewhenseoTitleunset.headline(long-form) kept for h1 + ogTitle (social cards have more room). Index page/conditionstitle also trimmed 61 β 38 chars. Sister of cannagent v3.304-v3.306 + glw v11.905 + scc v13.005.
v2.93.902026-05-10ProductionFixed
- π Telehealth city Γ condition pages had
| Green Wellnessduplicated in title β sister of v2.93.x locations-content.ts fix that swept 5 /locations/* + city-condition templates but missedlib/telehealth-condition-content.ts(a SECOND template with the same baked-in suffix pattern). Pre-fix every page like/telehealth/olympia-telehealth/alsrenderedTelehealth MMJ Card for ALS (Lou Gehrig's Disease) Β· Olympia Area, WA | Green Wellness | Green Wellness(108+ chars, brand twice, blown WAY past Google's 60-char SERP cap). Caught 2026-05-10 by /loop tick 5 cross-stack title-length re-audit (10 GW telehealth pages flagged at 100+ chars). Fix: dropped| Green Wellnessfrom the metaTitle template β root layout'stitle.template = '%s | Green Wellness'already appends it. Net: drops ~17 chars per page across ~60+ city Γ condition combinations, and Google's mid-string truncation behavior now starts mid-suffix instead of mid-condition-name. Sister discipline of glw v11.105 + scc v12.105 + cannagent v3.304+ title.absolute / template-suffix sweeps. tsc clean.
v2.93.852026-05-09ProductionFixed
- π Three dead links on the homepage qualifying-conditions section. /loop tick 3 cross-stack internal-link audit (homepage
β expect 200) caught/conditions/sleep-disorders,/conditions/nausea, and/conditions/tbi404'ing βConditions.tsxwas rendering ALL 13 entries from the masterCONDITIONSlist (booking-flow checkbox source-of-truth insrc/lib/constants.ts) ascards, but only 11 of those IDs have corresponding detail pages under/conditions/[slug]. Customers AND Google saw dead links from the highest-crawl-priority page on the site. Fix: introducedIDS_WITH_DETAIL_PAGEallowlist that's source-of-truth-aligned with the slugs inconditions-content.ts. Conditions in that set render as(clickable, navigates to detail page); conditions outside it render as plainbadges (visually identical, not clickable, no 404). The marketing message β "WA recognizes these qualifying conditions" β stays complete without 404'ing the click. When detail pages get added for the missing 3 (sister of the v2.93.45 MedicalWebPage wrapper added across the existing 11), graduate the ID from badge β link by adding the slug to the allowlist. Files:src/components/sections/Conditions.tsx. tsc clean.
v2.93.802026-05-09ProductionFixed
- π SEO duplicate-content bug on
flow.greenwellness.org. /loop tick 2 cross-stack canonical/og:url audit found that customer routes (/, /about, /faq, /conditions/*, /learn/*, etc.) all served 200 on BOTHflow.greenwellness.orgANDgreenwellness.orgwithpointing at apex. That's a confusing signal β Google's canonical-respecting indexing dedupes correctly, but non-canonical-respecting crawlers (some social unfurlers, AI search bots, lower-tier search engines) still index flow.* as duplicate content. Pre-existingFLOW_REDIRECT_TO_APEX=trueenv var only redirected the bare/path on flow.* β left every other customer path serving directly. Expanded proxy.ts flow-host logic to 308 ALL non-staff paths to apex (preserving path + query). NewisStaffPrefix(p)helper uses=== p || startsWith(p + "/")(NOT bare startsWith) so/dispensaries(customer plural) doesn't get accidentally classified as/dispensary(staff). Wildcard matcher added so proxy runs on customer routes β staff paths use slash-bounded negative lookaheads (admin/, notadmin) so plural customer routes match the wildcard. Still gated behind FLOW_REDIRECT_TO_APEX env β dormant on prod until Doug flips it. Pre-commit Explore review: identified the/dispensariescollision (already fixed in this push) + open-redirect concern (verified safe β Next normalizespathnamebefore middleware). tsc clean.
v2.93.752026-05-10ProductionChanged
- π§ͺ Test mode β Vercel auto-crons disabled (
vercel.json.crons = []) for Mariane's QA pass. Manual fires still work (curl withAuthorization: Bearer $CRON_SECRETorx-vercel-cron: 1header). Dr. Ari (ND) can log in and fill provider availability manually since the weeklyslotscron is paused. To restore the 14 cron schedules later, re-add the array tovercel.jsonβ full snippet preserved in this commit's parent (eab2c3e)git show eab2c3e:vercel.json. Crons paused: reminders, renewals, slots, weekly-digest, no-show, review-request, waitlist, reminders-2h, intake-reminder, daily-briefing, new-patient-drip, doh-nudge, eod-email, rc-webhook-renew. Doug-directed 2026-05-09 PT.
v2.93.702026-05-10ProductionFixed
- π Sister of v2.93.60 + v2.93.65 β added 3 cache pin entries I missed first pass:
/icon-192.png+/icon-512.png(route handlers ignore force-static alone for edge cache β sister vrg v0.13.2 same pattern) +/manifest.webmanifest(Next file convention samerevalidateno-op as sitemap.ts). Pre-fix all 3 servedcache-control: public, max-age=0, must-revalidatedespite v2.93.60 cache pin + v2.93.65 force-static export. Now: icon-192/512 24hr, manifest 1hr β matches the rest of the PWA cache surface.
v2.93.652026-05-10ProductionAdded
- π± PWA install infrastructure β 5 new files:
app/icon.tsx(32x32),app/apple-icon.tsx(180x180),app/icon-192.png/route.tsx(192x192 Android Chrome A2HS),app/icon-512.png/route.tsx(512x512 Android splash + maskable),app/manifest.ts(Web App Manifest). Pre-fix /icon /apple-icon /icon-192.png /icon-512.png /manifest.webmanifest all returned 404 β broke PWA install on iOS + Android, fell back to globe icon on Slack/Discord/RSS-reader previews + missing Add-to-Home-Screen branded shortcut for repeat-visit patients hitting /my-appointments / /reschedule / /referral surfaces. Brand palette mirrors OG image: dark navy #0f2744 background + #2d6a4f accent + Georgia serif. force-static export so Vercel emits with proper edge cache (manifest gets next.config.ts pin from v2.93.60). HIPAA-safe: manifest contains zero PHI, just brand identity. Cross-stack port from cannagent + vrg PWA pattern.
v2.93.602026-05-10ProductionFixed
- π Edge-cache pin for crawler-facing files. Cross-stack port from cannagent v4.685+v4.705+v4.725 + glw v11.605 + scc v12.605 + sureel + vrg. Next 16 ignores
export const revalidatefor MetadataRoute file conventions (sitemap.ts, robots.ts) β every Googlebot / Bingbot / GPTBot / ClaudeBot crawl + every favicon fetch was hitting Vercel function instead of CDN edge. PinnedCache-Controlheaders innext.config.tsheaders()function: sitemap 30min, robots/og 1hr, favicon/icon family 24hr. None of these paths contain PHI β sitemap/robots/icon are fully public-marketing surfaces. /llms.txt intentionally excluded from this list β it already serves max-age=86400 via in-route headers.
v2.93.552026-05-09ProductionAdded
- π©Ί
CollectionPageJSON-LD wrapper on/learn+/conditionsindex pages. Sister of/conditions/[slug]'sMedicalWebPagewrapper from v2.93.45. Pre-fix both index pages shipped onlyItemList+BreadcrumbListβ Google could see the article/condition list but couldn't tell what KIND of page is rendering it.CollectionPageis the schema.org standard for index/listing pages;mainEntityreferences the existingItemList(added@idto ItemList so the reference resolves) so the two nodes form one connected graph instead of two orphans. Both wrappers includeabout: MedicalSpecialty=Medical Cannabis Evaluation+audience: MedicalAudience=Patientfor medical-YMYL signal +publisher: #organizationIRI link to the layout's MedicalClinic node. Files:src/app/learn/page.tsx,src/app/conditions/page.tsx.
Fixed
- π
MedicalClinicJSON-LD now emits stable@id: /#organization. Pre-fix the site's other structured-data nodes (MedicalCondition.provider, MedicalWebPage.publisher, CollectionPage.publisher, Article.publisher) all referenced${SITE_URL}/#organizationvia IRI, but the MedicalClinic node returned bybuildMedicalBusinessLd()had no@idβ every reference was dangling. Google couldn't merge the publisher/provider nodes into the MedicalClinic entity, so the site's entire structured-data graph rendered as N orphans instead of one connected business entity. Critical for medical-content rich-result eligibility. Found while wiring CollectionPage in this same push. File:src/lib/seo.ts.
v2.93.502026-05-09ProductionFixed
- π©Ή PascalCase schema.org enum on
MedicalWebPage.aspect. v2.93.45 shipped the buggy camelCase (symptomsHealthAspect/treatmentsHealthAspect) β Explore review caught it pre-commit, but my staged version snapshotted the buggy line and the in-working-dir fix never made it into the commit. Now correct:SymptomsHealthAspect/TreatmentsHealthAspect. Lesson: reviewer-flagged fixes need explicit re-stage before commit;git addthen edit thengit commitcaptures the staged version, not the working-dir version.
v2.93.452026-05-09ProductionAdded
- π©Ί
MedicalWebPageJSON-LD wrapper on/conditions/[slug]β sister of/learn/[slug]'s existing["Article", "MedicalWebPage"]compound type. Pre-fix the 11 condition pages shipped only the bareMedicalCondition+FAQPage+BreadcrumbListnodes β Google can SEE the condition info but can't tell what KIND of page is rendering it.MedicalWebPageis the schema.org type Google's medical-YMYL ranking weighs explicitly (added to schema.org in 2014 for exactly this case). NewbuildConditionWebPageLd()helper insrc/lib/seo.tsreturns a node with@type=MedicalWebPage,aspect=[symptomsHealthAspect, treatmentsHealthAspect](per the WebpageAudienceType enumeration),aboutreferencing the existing#conditionIRI,mainContentOfPagelinking the same node,audience=Patient, andpublisherlinking the org IRI β so the four nodes (MedicalWebPage / MedicalCondition / FAQPage / BreadcrumbList) form one connected graph instead of four orphans. Wired intosrc/app/conditions/[slug]/page.tsx. Should bump rich-result eligibility for symptom + treatment query intents on the qualifying-condition pages. - π
/.well-known/security.txt(RFC 9116) β sister-port of glw + scc. HIPAA-aware: contact field with PHI-routing note (mark "PHI" in subjectso the 60-day breach-clock per 45 CFR Β§ 164.404 starts triaged), Expires 2027-05-10, out-of-scope list calls out third-party platforms (Practice Fusion, Salesforce, RingCentral, Resend, doxy.me, Stripe, Clerk, Vercel) so reporters know to disclose to the vendor. File atpublic/.well-known/security.txt. - π
/.well-known/change-password(W3C webappsec-change-password-url) β sister-port of glw + scc. Modern browsers (Safari Keychain, Chrome) probe this URL to offer "Change weak password" suggestions; pre-fix it 404'd β broken UX. GW has 3 separate auth surfaces (patient/provider/admin) so the redirect target needs picking β landed on/patient/loginsince patient is the largest cohort and the only group reachable from public credential-stuffing leaks. Forgot-password is an in-page mode toggle on the login form, not a standalone route. File atsrc/app/.well-known/change-password/route.ts.
Fixed
- π Meta description length β
/conditionstrimmed 195 β 142 chars +/faqtrimmed 200 β 148 chars. Both were truncating mid-list on Pixel-class viewport SERPs. Sister of v2.93.25's title-tag length sweep (3 pages under 60-char SERP cap) but for description tags. Files:src/app/conditions/page.tsx,src/app/faq/page.tsx.
v2.93.402026-05-09ProductionFixed
- πͺ /providers redirect actually fires now. v2.93.30 deleted the public pages + added a 308 redirect to / inside proxy.ts, but the export const config.matcher didn'''t include /providers, so proxy never ran on those paths and Next fell through to a 404. Verified live: curl https://greenwellness.org/providers returned 404 instead of 308. Fix: add /providers + /providers/:path* to matcher list. Sister-of: any future redirect added to proxy.ts must also add the corresponding matcher entry β proxy doesn'''t auto-match every path. Memory recipe candidate.
v2.93.352026-05-09ProductionAdded
- π₯
/admin/leadsβ work-the-queue surface for inbound web leads. ReadsLEAD_CAPTURED+ newLEAD_CONTACTEDrows fromAuditLog(last 30 days) in one query, parses the/api/leads-written detail string (sf=β¦ contact=β¦ reason_len=N firstName=β¦ lastName=β¦ email=β¦ phone=β¦) and renders a table with All / New / Contacted filter chips, today-count + 30d-total, and aMark contactedbutton per row. Mark-contacted writes aLEAD_CONTACTEDaudit row whoseresourceIdpoints at the original capture row, so the page can pair them up cheaply. **No outbound notification** β Salesforce already runs auto-responses on every lead and Doug 2026-05-09 explicitly does NOT want Flow side double-touching the lead while SF stays primary CRM this week. Pure queue-management surface so customer service can work leads in Flow + so the EOD email'sLEAD_CAPTUREDcount graduates from "happens in SF" to a number Doug can drill into. ADMIN/MANAGER gated; HIPAA-adjacent fine-print + audit-log link in the page footer. Files:src/app/admin/leads/page.tsx,src/app/admin/leads/MarkContactedButton.tsx(client component, idempotent POST + router.refresh on success),src/app/api/admin/leads/[leadAuditId]/mark-contacted/route.ts(verifies admin session, blocks duplicateLEAD_CONTACTEDrows with 409, audits with the staff member's name from the session). NewLEAD_CONTACTEDaction added toAuditActionunion insrc/lib/audit.ts. AdminNav gets a "Leads" entry under the Patients link with aInboxicon; MANAGER allow-list updated.
v2.93.302026-05-09ProductionRemoved
- πͺ
/providersindex +/providers/[slug]detail pages removed per Doug 2026-05-09 directive ("take the providers page off greenwellness"). Files deleted:src/app/providers/page.tsx,src/app/providers/loading.tsx,src/app/providers/[slug]/page.tsx. Reference sweep: sitemap.ts drops both /providers static entry + providerEntries (per-provider URLs) + the unuseddb.provider.findManyfetch +toProviderSlugimport. SiteNav.tsx drops the "Providers" nav link. HomeContent.tsx drops thesection render +Physiciansimport + the "Our Providers" link in the row of homepage anchors. proxy.ts now 308-redirects/providers+/providers/[slug]to/so existing SERP entries + bookmarks land on the homepage instead of 404./admin/providers(the internal admin tool for managing providers) is unaffected β it's a separate surface, never customer-facing. **Note for the next agent:**src/components/sections/Physicians.tsxstill exists in the repo but is now unused; left in place rather than deleted in case Doug wants to repurpose it later. Also retained:src/api/public/providers/route.ts(still callable; safe to remove if no internal consumer; defer to Doug). Tsc clean against source (Next.js .next/types/validator cache flags the deleted routes β regenerates on next build, ignored).
v2.93.252026-05-09ProductionChanged
- π Title-tag length sweep β 3 page titles trimmed under Google's ~60-char SERP cap (auto-appends
| Green Wellness~17 chars)./pricing(63β47): "Medical Marijuana Card Cost β Washington State" β "Medical Marijuana Card Cost β WA"./conditions(80β47): "Qualifying Conditions for Medical Marijuana in Washington State" β "Qualifying Conditions β WA Medical Marijuana"./locations(89β44): "Green Wellness Locations β Medical Marijuana Clinics in Washington State" β "Locations β WA Medical Marijuana Clinics". Sister of glw v11.105 + scc v12.105 cross-stack title sweep. Caught by /loop saturation grind 2026-05-09 cross-stack title-length audit.
v2.93.202026-05-09ProductionChanged
- π©Ί Cron-schedule jitter β spread 4-cron herd at
0 16 * * *to :03 renewals + :09 intake-reminder + :17 new-patient-drip + :24 doh-nudge. Pre-fix all 4 fired at exact same minute against the same Neon DB + Resend (renewals + intake-reminder both send patient SMS, doh-nudge + new-patient-drip both send email). Sister of inv v375.805 cross-repo cron-jitter sweep (which spread 21 crons across 4 herds). Per CronCreate doc: pick a minute NOT 0 or 30. No semantic change β daily cadence preserved, all stale-actor math still holds.
v2.93.152026-05-09ProductionChanged
- π Permissions-Policy adds
interest-cohort=()(FLoC/Topics opt-out) β HIPAA-relevant since patient browsing on telehealth pages shouldn't be aggregated into ad-cohort signals. Pre-fix:camera=(), microphone=(), geolocation=()only. Post-fix: addsinterest-cohort=()for parity with Sureel + cannagent + glw + scc + (now) VRG cross-stack pattern. GW was the lone outlier across the 6-site stack curl audit (caught by /loop saturation grind 2026-05-09 cross-stack security-headers comparison).
v2.93.102026-05-09ProductionFixed
- π‘οΈ Invalid-date RangeError guard on
/admin/promo-codesAdd Code form βfield could produce a truthy non-empty string thatnew Date(x)parses to Invalid Date, and.toISOString()on Invalid Date throws RangeError. Pre-fix throw bubbled out of click handler β React error boundary β admin Add-Code panel tanked mid-create. Post-fix:isNaN(parsed.getTime())guard before serializing, with inline error 'Pick a valid expiry date (or leave blank for never).' Sister of inv v365.005 client-side date-guard fix. GW lone outlier from cross-repo audit (glw + scc + Sureel + cannagent all clean on this pattern).
v2.93.052026-05-09ProductionChanged
- π sitemap.xml + robots.txt CDN cache (cross-repo port of inv v342.605 + v342.405 OG cache). Pre-fix both endpoints served
cache-control: public, max-age=0, must-revalidate(Next.js default for metadata routes) β every Google/Bing/AI-bot crawl re-rendered. Sitemap pulls from DB (providers, articles via publishedAt) + multiple constants β 30-min revalidate balances freshness vs cache benefit. Robots is fully static β 1-hour revalidate. Addedexport const revalidate = 1800(sitemap) +revalidate = 3600(robots). Sister of inv v342.605 + glw v8.165 + scc v9.385 cross-repo CDN-cache sweep.
v2.92.252026-05-09ProductionFixed
- π‘οΈ HIPAA-bearing canonical-URL allow-list defense β
lib/app-url.tsCANONICAL_APP_URLwas using deny-list-only.vercel.apprejection. Same vulnerability that broke STAFF_APP_URL on inv prod for 24h: env was set toapp.{store}(404'd subdomain), passed the deny-list check, every canonical/sitemap/email-deeplink URL pointed at a 404. Worse blast radius here: HIPAA-bearing email deep-links (cancel/reschedule/referral) landing patients on a wrong/dead URL is a privacy concern. Now allow-list requireshostname β {flow.greenwellness.org, greenwellness.org, www.greenwellness.org}. Defense-in-depth β even if Doug-action env-var fix is missed, code falls through to the canonical fallback. Sister of inv v337.005 + v338.005 + v340.605 + v340.805 + glw v8.145 + scc v9.365 cross-repo allow-list defense sweep. tsc clean.
v2.92.052026-05-09ProductionChanged
- π EOD email reframed for soft-launch posture (Doug 2026-05-09: 'send me a email at end of day, how many new leads, how many calls, etc').
/api/cron/eod-emailnow leads with **lead-pipeline + booking + call counts** instead of staff-productivity. (1) AddedLEAD_CAPTUREDaudit-log count + Appointment count for the day. (2) Header subline now:N new leads Β· M bookings Β· Xβ Yβ calls Β· Z voicemail Β· β¦staff (when present). (3) 3-tile header reworked: New leads / Bookings / Calls (replaces Total actions / Active staff / Top performer). Top-performer card moves below as a smaller surface, only renders when staff actions exist. (4) Skip-gate now checks ALL signals (logs + leads + bookings + calls); pre-fix the cron skipped on 'no staff activity' which meant Doug got NO email on soft-launch days with leads but zero clinic activity. (5) Subject line leads with leads + bookings counts; falls back to staff productivity language only when zero leads/bookings. (6) Heartbeat now recordsleads=N bookings=M calls=X actions=Yfor cross-day trend.ADMIN_NOTIFY_EMAILset todoug@greenwellness.orgon Vercel β Doug gets the daily 5pm PT recap directly. tsc clean.
v2.91.652026-05-09ProductionFixed
- π¨ v2.91.45 theme-color was a no-op β Next.js 14+ moved
themeColorout ofmetadatato a separateviewportexport, so settingmetadata.themeColorsilently did nothing. Caught in post-deploy verification:was empty in rendered HTML despite v2.91.45 declaringthemeColor: "#0f2744"in the metadata object. Fixed: moved toexport const viewport: Viewport = { themeColor: "#0f2744" }. Comment doc updated. Mobile Chrome / Safari now paint the address bar with GW brand navy. tsc clean.
v2.91.552026-05-09ProductionAdded
- π¨ **UX gap caught: homepage had ZERO links to
/get-started.** The new soft-launch lead-capture page shipped in v2.88.05 + verified live at apex post-cutover, but the homepage hero only exposed 'Book my appointment' (opens full booking wizard) + the phone number. No path for visitors who prefer 'request a callback within 1 business day' over committing to a 5-step wizard. Soft-launch traffic landing on/would never discover the gentler entry. **Fix:** added a 'Request a callback' secondary CTA to the homepage Hero next to 'Book my appointment', linking to/get-started. Equal-weight visual treatment (white-outline button, same height + spacing as the phone-number link). Comment doc explains why the lane matters pre-BAA. Caught while auditing internal-link discovery as part of the SEO cleanup pass βgrep -oE 'href="/get-started'on rendered homepage HTML returned zero matches before this fix. tsc clean.
v2.91.452026-05-09ProductionAdded
- π¨ Mobile chrome theme-color β
(GW brand navy) renders site-wide via root layout'smetadata.themeColor. Mobile Chrome + Safari paint the browser address bar with this color when the site loads, giving a polished branded chrome on phones. Sister pattern to VRG + Sureel landing pages. Same value for light + dark mode (no dark-mode UI to swap to). Trivial-but-noticeable mobile-first polish for soft-launch traffic.
v2.91.352026-05-09ProductionFixed
- π¨ **Real bug: 5 page titles had 'Green Wellness' duplicated** because the per-page
metaTitlebaked in| Green Wellnessand the root layout'stitle.template(%s | Green Wellness) appends it again. Pre-fix/locations/spokanerendered(93 chars, brand twice). Sister bug onMedical Marijuana Card Spokane, WA | Green Wellness β Same-Day Authorization | Green Wellness /locations/lynnwood,/locations/olympia,/locations/vancouver, plus thecity-condition-content.tstemplate that's used for ~43 city Γ condition pages. **Fix:** stripped| Green Wellnessfrom per-pagemetaTitleinlib/locations-content.ts(4 fields) + the city-condition template (1 line). Layout template now appends the brand once, no duplication. Net char savings: 17 chars per affected title (e.g. Spokane goes from 93 β 76). Still some titles over Google's 60-char SERP truncation budget on long cities Γ long conditions β that's a separate tightening pass deferred for now since the headline keywords still appear in the leftmost portion. tsc clean.
v2.91.252026-05-09ProductionAdded
- π©Ί SEO post-launch β
MedicalConditionJSON-LD on all 11/conditions/[slug]pages. Pre-fix the pages rendered onlyFAQPage+BreadcrumbListschema β Google had no signal that the page was ABOUT a specific medical condition. Now ships aMedicalConditionnode per slug (chronic-pain / ptsd / anxiety / cancer / multiple-sclerosis / epilepsy / crohns-disease / glaucoma / parkinsons-disease / hiv-aids / als) with:name,description,audience: MedicalAudience{Patient},relevantSpecialty: MedicalSpecialty{Medical Cannabis Evaluation},possibleTreatment: MedicalTherapy(the WA medical-cannabis evaluation linked to/get-started, with provider@id-linked to homepage MedicalClinic β no duplicate node). YMYL signal Google weighs for medical-content rich-result eligibility. NEWbuildMedicalConditionLd()helper inlib/seo.ts(sister ofbuildArticleLd/buildPhysicianLd/buildLocationLd) β comment doc explains whypossibleTreatmentis honest-by-design (an evaluation IS the therapy in our model; we never claim cannabis cures the condition). tsc clean.
v2.91.152026-05-09ProductionAdded
- π SEO post-launch β sitemap submitted across platforms. Both Google's
/ping?sitemap=(deprecated 2023) and Bing's equivalent (returned 410 today) are dead, so anonymous one-shot submission isn't an option anymore. Switched to the IndexNow protocol β one POST fans out to Bing + Yandex + Naver + Seznam + Mojeek (the search engines that participate). Setup: (1) generated 32-char hex key, (2) wrotepublic/containing the key (search engines fetch this to verify domain ownership before honoring submissions), (3) shipped.txt scripts/ping-indexnow.mjsreading the sitemap + POSTing toapi.indexnow.org/indexnowwith the URL list, with a key-file reachability pre-check that fails fast if the file 404s. Re-runnable anytime β pass URLs as args to submit specific pages, or omit args to submit the full sitemap (currently 356 URLs). Run vianode scripts/ping-indexnow.mjs. Doug-action remaining for the holdouts: Google Search Console + Bing Webmaster Tools verification (both require dashboard auth). - π‘οΈ Search engine verification meta-tag scaffold in root layout β
metadata.verification.googlefor Search Console,msvalidate.01for Bing Webmaster Tools,yandex-verificationfor Yandex. All env-gated (GOOGLE_SITE_VERIFICATION/BING_SITE_VERIFICATION/YANDEX_VERIFICATION) β empty env renders no tag (Next.js handles this cleanly via undefined-skip). When Doug pastes a verification token from any of those dashboards, the meta tag appears site-wide on next deploy. .env.example updated with each var + the dashboard-URL recipe.
v2.91.052026-05-09ProductionAdded
- π **APEX DNS CUTOVER COMPLETE β
https://greenwellness.orgis LIVE on Vercel.** The 8-year WordPress era ends; soft-launch posture begins. **Sequence executed:** (1) Backed up pre-cutover GoDaddy DNS state to/tmp/gw_dns_backup_20260509_084745.json(rollback insurance). (2) Verified MX records intact (Proofpoint β M365 chain) β must NOT touch since GW email delivery rides on them. (3) Set 3 soft-launch env vars on Vercel production:NEXT_PUBLIC_MANUAL_CALLBACK_MODE=true(booking-confirmation copy says 'we'll call you back' instead of automated-email),PAYMENT_DEFERRED=true(server-side β/api/appointmentsaccepts bookings without Stripe),NEXT_PUBLIC_PAYMENT_DEFERRED=true(client-side βStepPaymentskips Stripe Elements). (4) Triggered production redeploy. (5) Addedgreenwellness.org+www.greenwellness.orgas custom domains on the green-wellness Vercel project (onlyflow.*was attached pre-cutover). (6) Flipped apex A record at GoDaddy via API:PUT /v1/domains/greenwellness.org/records/A/@body[{"data":"76.76.21.21","ttl":600}](Vercel's apex IP, 10-min TTL for fast rollback). (7) DNS propagated to public resolvers within ~5min. (8) Vercel auto-issued apex + www SSL certs via DNS-01 (no manualvercel certs issueneeded β clean issuance). (9) UpdatedNEXT_PUBLIC_APP_URLfromhttps://flow.greenwellness.orgtohttps://greenwellness.orgso emails / OG images / sitemaps / internal links all use the new canonical. (10) Verified all critical paths:/api/health200 + sha matches,/get-started200,/sitemap.xml356 entries,/llms.txtreferences apex everywhere,/pricing200, homepage 200. **What's deferred for later (not launch-blocking):**FLOW_REDIRECT_TO_APEX=trueflip to make flow.* staff-only entry β kept open as alternate access during validation period. Postmark/SES BAA β flips offMANUAL_CALLBACK_MODE. Stripe live keys + webhook β flips offPAYMENT_DEFERRED. Twilio HIPAA BAA + A2P 10DLC β enables SMS reminders. Anthropic Enterprise BAA β enables AI Drafts. **Rollback recipe in LIVE.md** if anything goes red. **9-day arc**: 2026-05-01 first GW commit shipped β 2026-05-09 apex live.
v2.90.152026-05-09ProductionChanged
- βοΈ Soft-launch copy polish across the lead-capture + booking-confirmation surfaces patients see during the manual-callback window.
get-started/page.tsxβ reassurance tile #2 reframed ("Same-week appointments" β "Usually within the week", lede leads with speed not the callback mechanic) and tile #3 sharpened ("No automated approvals" β "Every evaluation is done by a person, not a form").get-started/LeadForm.tsxβ disclaimer dropped the weasel-hedge "We'll never share" for the more accurate "Your information stays with Green Wellness"; success-state body tightened ("A member of the Green Wellness team will reach out" β "Someone from our team will call") and "need us sooner?" reads warmer than "if you need us sooner". Reason placeholder went from "with our doctor" (singular, presumes assignment) to "with the provider" (matches our actual provider language).StepConfirmation.tsxMANUAL_CALLBACK_MODE branch β "A staff member will call ... within 24 hours" β "We'll call ... within 1 business day" (matches /get-started copy + matches what staff actually commits to); telehealth-link line untangled from the awkward "when staff calls to confirm" subordinate clause; share-via-text body de-dispensary-fied ("Check it out" β patient-voice).TrustBar.tsxβ privacy footnote restructured so the link label leads ("What we collect and how it's protected: HIPAA Notice...") rather than burying the value behind "See our...".faq-data.tsβ 6 answers tightened: Q1 hedge-fix ("we encourage you to book" β "book a visit"), Q4 mealiness-fix ("anyone who believes they could benefit" β "anyone who thinks medical cannabis might help"), Q12 "potentially" hedge cut (the tax exemption IS real per HB 1453), Q13 added the actual RCW citation (69.51A) where there was just "RCW laws", Q15 bureaucratic verb cut ("is designed to issue" β "ends with your written authorization in hand"), Q16 lede inverted ("We believe in transparent pricing" β "Because you should know what something costs before you book it").HowItWorks.tsxβ step 4 description rewritten so the optional-but-useful framing leads with the patient benefit ("raises your possession limits and adds legal protections") rather than the registry mechanic. No regulatory citations, pricing, schema.org strings, or hardcoded contact constants changed; tsc clean.
v2.90.052026-05-09ProductionAdded
- π SEO β
/get-startedService JSON-LD for SERP rich-result eligibility. Pre-fix the page only had BreadcrumbList (single schema, no service description) β Google would treat it as a generic page rather than a medical-evaluation lead-capture surface. Sister-pattern to/telehealth(MedicalTherapy + MedicalClinic) and/pricing(OfferCatalog). Added Service schema with: name='Washington State Medical Marijuana Evaluation', areaServed=Washington, provider=existing MedicalClinic node (@id-linked, no duplication), potentialAction=ReserveAction with urlTemplate + result=Reservation. Tells Google + AI crawlers this page IS the canonical 'request a medical evaluation' surface for the service. Audit baseline: homepage = 12 schemas, /telehealth = 10, /pricing = 10, /get-started now = 5 (Breadcrumb + Service nodes). tsc clean.
v2.89.952026-05-09ProductionFixed
- π SEO go-live audit round 2 β 2 real fixes from h1 + canonical sweep across sitemap. **Fix #1 β
/dispensarieshad ZERO h1**: only an h2 'Partner Dispensary Directory' insidesection component. Page-with-zero-h1 = soft-404 signal to Google + screen-reader landmark gap. Directory is single-consumer (onlysrc/app/dispensaries/page.tsxper grep), safely bumped h2 β h1. **Fix #2 β/my-appointmentswas in sitemap BUT layout sets noindex AND robots.txt Disallows it** β three-layer signal mismatch. Sitemap-vs-noindex inconsistency signals 'thin content' to Google + wastes crawl budget. Page is PHI-adjacent (patient magic-link login + token portal) so noindex is correct; sitemap entry was the bug. Removed from sitemap, kept noindex + Disallow as canonical signals. tsc clean.
v2.89.752026-05-09ProductionFixed
- π©Ί
/api/ogroute now honors&kicker=query param. Pre-fix: the parallel session that shipped v2.88.05's/get-startedpage added&kicker=Washington Medical Marijuana Evaluationsto its OG URL builder, expecting that to replace the hardcoded 'Washington State' label next to the brand mark. The route never read the param β it was silently ignored on every social-share render. So the new lead-capture page's social-preview card showed the generic 'Washington State' kicker instead of the contextual 'Washington Medical Marijuana Evaluations' message. Now readssearchParams.get("kicker")withMAX_KICKER = 60cap (DOS defense) and a fallback to the original 'Washington State' string. tsc clean.
v2.89.652026-05-09ProductionAdded
- π¦ Post-cutover host redirect β
flow.greenwellness.org/βhttps://greenwellness.org/admin/login308 (Doug 2026-05-09: 'flow should go to the admin login page'). Env-gated behindFLOW_REDIRECT_TO_APEX=trueso it stays OFF pre-DNS-cutover (apex still on Sucuri/WordPress, would 404). Once Doug flips DNS apex β Vercel + sets the env var,flow.*root collapses to apex/admin/login. Internal/admin/*paths onflow.*still pass through unchanged so deep-linked admin pages keep working without a hop. **Doug-action sequence post-cutover:** (1) flip apex DNS A record β Vercel76.76.21.21; (2) verify SSL auto-issue + apex serves the new app; (3) set Vercel envFLOW_REDIRECT_TO_APEX=true+ redeploy; (4) verifycurl -I https://flow.greenwellness.org/returns308 β https://greenwellness.org/admin/login.
v2.89.252026-05-09ProductionFixed
- π¨ **REAL BUG: legacy WordPress traffic landed on homepage but booking wizard never opened.**
BookingParamHandler(the homepage's auto-open-wizard hook) checked onlysearchParams.get("book") === "true", but the 8 next.config.ts legacy redirects (/book-now,/intake-form,/renewal-patients,/new-patient,/new-patient/intake,/new-patient/intake2,/new-patient/renewal-patients, and previously/get-started) all 308 β/?book=1(with1, nottrue). So someone clicking a Google-indexed/book-nowlink landed on/with the booking wizard CLOSED β patients had to find the CTA themselves after the 308. The legacy redirects shipped in v2.73.20 (sitemap preservation) but the handler was never extended to accept the normalized=1value. Fix:BookingParamHandlernow accepts BOTH=true(SiteNav-emitted canonical) AND=1(legacy redirect target). Comment documents the gotcha. tsc clean.
v2.89.152026-05-09ProductionFixed
- π‘οΈ PHI-leak hardening β
lib/practicefusion.tscreatePatient()+createFhirAppointment()boundaries (sister of v2.89.05 SF lift). Both functions threwnew Error(\... ${await res.text()}\)embedding the raw PF API response body in the thrown error's message. PF (FHIR R4 EMR) error envelopes echo patient demographics back ('Patient.name.family must be set: "Doe"'). Same threat model as the SF fix β any caller that logserr.messageorerr.stackleaks PHI. Now both throw withstatus=${res.status}only and drain the response. Closes theawait res.text()in throw-message pattern across the entire repo (zero remaining sites). tsc clean.
v2.89.052026-05-09ProductionFixed
- π‘οΈ PHI-leak hardening β
lib/salesforce.tscreateLead()boundary. Pre-fix: when the SF API returned non-OK, the helper threwnew Error(\Salesforce Lead creation failed: ${await res.text()}\)β embedding the raw SF response body in the thrown error's message. SF error envelopes routinely echo input field values back ('FirstName must not be empty: Jane Doe'), so any caller that logserr.messageorerr.stackwould leak Lead PII. Both current callers (api/leads,api/integrations/salesforce) safely useerr.nameonly, but defending at the boundary closes the class for any future caller. Now drains the response (releases connection) and throws withstatus=${res.status}only. Sister of the v2.83.75 round 4+5 + v2.86.35 round 7 PHI-leak hardening sweeps; same shape β keep raw vendor-error bodies out of any object that might get logged. tsc clean.
v2.88.952026-05-09ProductionFixed
- π©Ί
/admin/audit-loglabel + color maps β addedLEAD_CAPTURED(emerald, 'Captured web lead') so the new v2.88.45 audit rows render with a label instead of the raw enum string. Sister-fixed 3 pre-existing gaps caught while there:CRON_HEARTBEAT(neutral stone β lots of these fire so shouldn't compete with PHI-rows),GBP_OAUTH_CONNECTED/GBP_OAUTH_DISCONNECTED(sky-blue β low-frequency operational event). Each entry has a documenting one-line comment. The audit-log filter dropdown also picks up these actions automatically (it reads fromObject.keys(ACTION_LABELS)at line 332). tsc clean.
v2.88.852026-05-09ProductionAdded
- π₯ SEO completeness sweep on
/get-started(sister of v2.86.25 BreadcrumbList sweep): (1) BreadcrumbList JSON-LD added β was the one public marketing page without it (the v2.86.25 sweep ran 2 days before /get-started landed in v2.88.05). 2-level breadcrumb (Home βΊ Get Started). (2) Sitemap entry added β was unlisted, so Google wouldn't have discovered it organically; nowpriority: 0.95(just under the homepage's 1.0, since /get-started is the soft-launch entry CTA). (3)llms.txtextended with a 'request a callback (no booking commitment)' line pointing AI search engines (ChatGPT/Claude/Perplexity) at /get-started as the gentler entry path alongside /?book=true. tsc clean.
v2.88.752026-05-09ProductionFixed
- π¨ **Real customer-facing copy bug:
/get-startedclaimed in-person appointments are available in Wenatchee.** Green Wellness has NO Wenatchee clinic β the in-person locations are Lynnwood, Spokane Valley, Olympia, and Vancouver, WA. Wenatchee IS a TELEHEALTH service area (cross-Cascades patients can do video visits from anywhere in WA) AND a dispensary-directory entry (where patients can buy after getting their card), but is NOT a GW clinic. The parallel session that shipped v2.88.05's/get-startedWeb-to-Lead landing crossed wires with the GreenLife Cannabis project (Wenatchee-based β sibling repo under/CODE/). A patient driving from Wenatchee for an in-person visit would have arrived to no clinic. Now reads 'In-person also available at our Lynnwood, Spokane Valley, Olympia, and Vancouver clinics.' Cross-checked all other Wenatchee references insrc/βtelehealth-cities.tsanddispensaries.tsentries are correct in their context; only the /get-started copy was the bug. tsc clean.
v2.88.652026-05-09ProductionFixed
- π SEO go-live audit β caught 1 unported WordPress URL:
/intake(bare, no token) returned 404 on the new site. WordPress had/intake/as an entry-point CTA per the Rank Math sitemap; on the new appsrc/app/intake/exists but only as a layout +[token]dynamic route (post-booking patient intake form) β bare/intakehad nopage.tsx. WP-era inbound links + Google-indexed entries were dead-ending in 404 instead of converting. Added/intake β /get-startedredirect (the soft-launch lead-capture page is the right next step for 'I want to start an evaluation' intent). Once the booking platform graduates from test, retarget to/?book=1. Audit recipe:for path in $(curl -fsS https://greenwellness.org/page-sitemap.xml | grep -oE 'β flag any WP URL that 404s on the new site. With this fix all 30 WP-sitemap URLs port cleanly to flow + will work post-DNS-cutover. Sister checks confirmed clean: 79/80 redirect sources resolve (1 false-positive was a[^<]+' | sed 's| ||;s|https://greenwellness.org||'); do clean=$(echo $path | sed 's|/$||'); status=$(curl -sI https://flow.greenwellness.org$clean | head -1 | awk '{print $2}'); [ "$status" = "404" ] && echo "404 $clean"; done /(.*)regex), 356/356 sitemap URLs return 200/308. tsc clean.
v2.88.452026-05-09ProductionFixed
- π¨ **REAL BUG (latent in v2.88.05):
/api/leadswould silently LOSE leads if Salesforce was down.** Pre-fix: SF-push success path onlyconsole.log'd the sfId; SF-failure path onlyconsole.error'd the error class β neither persisted the lead anywhere queryable by staff. Effect: if Salesforce was down for an hour during soft-launch traffic, every lead in that window would be unrecoverable (Vercel logs aren't a reconciliation surface). Now every successful POST writes anAuditLogrow with actionLEAD_CAPTUREDREGARDLESS of SF outcome β sf=ok|down|skipped, plus name/email/phone/contact-preference/reason-length, withresourceId=sfIddeeplinking to the SF Lead when push succeeded. Lead-data is in BAA-covered Postgres so Doug can reconcile from/admin/audit-logif SF goes down. Fail-open user-facing semantics preserved (returns{ok:true}even on SF outage, so the patient-side form doesn't break). AddedLEAD_CAPTUREDtoAuditActionunion with documenting comment block. tsc clean.
v2.88.352026-05-09ProductionAdded
- π‘οΈ NEW build-gate
scripts/check-redirect-shadow.mjsβ pins the v2.88.25 fix against regression. Fails the build when anyredirects()source:path innext.config.tsmatches a realsrc/app/. The bug class: Next.js applies/page.tsx redirects()BEFORE routing to pages, so a legacy redirect entry can silently shadow a brand-new page (the v2.88.05/get-startedlead-capture landing was unreachable for ~6 hours because the v2.73.20 WordPress sitemap-preservation entry was still in the redirect list). Gate parses 80 redirect sources and cross-references each againstsrc/app/. Wired intopackage.jsoncheck:allumbrella +.githooks/pre-push(7/7 gates). Pre-push reportsβ check-redirect-shadow: 0 redirects shadow existing pages (80 sources scanned). Sister of the 6 existing structural gates β same shape, same EXEMPT-with-rationale convention.
v2.88.252026-05-09ProductionFixed
- π¨ **REAL BUG:
/get-startedWeb-to-Lead landing page was 308-redirecting to /?book=1 β page unreachable in prod.** v2.88.05 shipped a brand-new lead-capture landing atsrc/app/get-started/page.tsxfor soft launch, butnext.config.ts:85had a legacy{ source: "/get-started", destination: "/?book=1", permanent: true }redirect entry from the v2.73.20 WordPress Rank Math sitemap-preservation ship. Next.js appliesredirects()BEFORE routing to pages, so everyflow.greenwellness.org/get-startedrequest returned308 β /?book=1instead of serving the new page. Caught by post-deploy verification βcurl -I .../get-startedreturned 308. Removed the entry fromredirects(). The new landing page is a much better destination for legacy traffic anyway (a soft lead form is gentler than a 5-step booking wizard for someone following a 2-year-old WordPress link). Added a comment documenting the gotcha so future redirect-list adds verify they don't shadow a realsrc/app/. Audited all 80 other redirect entries β none shadow existing pages. tsc clean./page.tsx
v2.88.152026-05-09ProductionFixed
- π©Ί RC_SERVER SSoT consolidation β 3 identical
process.env.RC_SERVER_URL || "https://platform.ringcentral.com"declarations acrosslib/ringcentral.ts+api/cron/rc-webhook-renew/route.ts+api/admin/messages/[id]/recording/route.tsconsolidated to a singleRC_SERVERexport fromlib/ringcentral.ts. The 2 sister callers nowimport { RC_SERVER }instead of reimplementing the env+fallback. If RC ever changes their endpoint OR if Doug needs a sandbox URL, it's a one-constant edit. Same shape as the v2.81.95 CANONICAL_APP_URL SSoT consolidation. tsc clean.
v2.88.052026-05-09ProductionAdded
- π **
/get-startedinterim landing page β Web-to-Lead bridge for soft launch.** Doug 2026-05-09: ship the new site live TODAY with a lead-capture form posting to Salesforce, while the full booking platform stays in test until Postmark BAA + Stripe live + Neon prod blockers clear. Two new files:src/app/get-started/page.tsx(server component β branded landing with 3-tile reassurance + sign-in / call CTAs) +src/app/get-started/LeadForm.tsx(client component β name/email/phone/optional 'what brings you here?' + contact-preference pill picker, honeypot field, 3/min rate-limit, fail-open on SF push errors so we never lose a lead). New API routesrc/app/api/leads/route.tsPOST: anonymous, IP-rate-limited 3/min, validates input, calls existingcreateLead()helper fromlib/salesforce.ts(synthesizesappointmentType: 'WEB_LEAD_INTERIM'so SF reports can split web-leads from booked-patients), fail-open if Salesforce push errors (logs class name + ipPrefix only β no PHI). Phone CTA pulls fromPHONEconstants SSoT. Once BAAs land + booking platform graduates from test,/get-startedstays as the softer 'not ready to book yet' lead-capture path alongside the full funnel. tsc clean. **Doug-action:** confirmSF_CLIENT_ID/SF_CLIENT_SECRET/SF_INSTANCE_URLenv vars are set on the Vercel project β without themcreateLead()early-returns null + the form silently drops the lead (only Vercel log entry remains). Verify withcurl -X POST https://greenwellness.org/api/leads -H 'Content-Type: application/json' -d '{"firstName":"Test","lastName":"Lead","email":"test+launch@example.com"}'after deploy β should return{ok:true}AND a Lead row should appear in SF within 30s.
v2.87.352026-05-09ProductionFixed
- π¨ **Real bug: copy-paste migration recipe on /admin/launch referenced wrong env var.**
src/app/admin/launch/page.tsx:627renderednode -e "... process.env.DB_URL ..."as the one-liner Doug copies to apply un-applied migrations. The actual env var isDATABASE_URLβDB_URLis undefined. If Doug ever copy-pasted the recipe to apply a migration,postgres(undefined, ...)would throw before the SQL ever ran. Caught while comparingprocess.env.Xreferences vs.env.exampledocumented vars (sweep flagged 6 undocumented env-var lookups; only this one was a real typo, the rest were Vercel-injected or intentional kill-switches). Now referencesDATABASE_URL. tsc clean. - π
.env.exampleexpanded β added 18 previously-undocumented env vars:EMAIL_FROM/EMAIL_REPLY_TO(vendor-agnostic email config) Β·POSTMARK_API_KEY+POSTMARK_INBOUND_AUTH+POSTMARK_STREAM(HIPAA-safe path) Β·AWS_SES_REGION+AWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEY(alt HIPAA-safe path) Β·AI_DRAFTS_ENABLEDΒ·EMAIL_AUTO_ACK_ENABLEDΒ·NEXT_PUBLIC_MANUAL_CALLBACK_MODEΒ·NEXT_PUBLIC_PAYMENT_DEFERRED+PAYMENT_DEFERREDΒ·HIPAA_COMPLIANT(attestation flag) Β·RC_WEBHOOK_VERIFICATION_TOKENΒ·RC_SERVER_URLΒ·RC_FROM_NUMBER(kill-switch β DO NOT SET in prod). Each entry has a comment block explaining what flipping it does + which BAA/vendor work it gates. Closes the documentation drift betweenprocess.env.Xlookups in code and.env.example.
v2.87.252026-05-09ProductionFixed
- π¨ **REAL BUG:
/api/integrations/emailroute bypassed the HIPAA-vendor-abstraction layer.** The booking-confirmation email path used by/api/appointments(real customer bookings) and/api/admin/appointments/manualhithttps://api.resend.com/emailsdirectly via fetch with hardcodedfrom: "Green Wellnessand" RESEND_API_KEYcheck β bypassinglib/email.ts+lib/workflow.ts's vendor tier ordering (Postmark > AWS SES > Resend). Effect: when Doug signs the Postmark or SES BAA and setsPOSTMARK_API_KEY/ SES creds, this route would STILL send via Resend (no BAA) β silently breaking the HIPAA-safe email path for booking confirmations on Day 1 of HIPAA-vendor activation. Now usessendEmail()fromlib/workflow.tswhich respects the vendor abstraction + EMAIL_FROM env var. Removed the staleRESEND_API_KEYskip-check (sendEmail returns false β 500 if no vendor configured, with structural failure audited to/admin/launch). - π©Ί NEW
getFromAddr()exported fromlib/email.tsβ returns the resolvedEMAIL_FROMenv var value (or canonical DEFAULT_FROM"Green Wellness)." admin/messages/send/route.ts:214was independently reimplementingprocess.env.EMAIL_FROM || "no-reply@greenwellness.org"for itspatientMessage.fromAddrcolumn β but with a different fallback (no"Green Wellness <>"name prefix). Result: when EMAIL_FROM is unset, the audit row stored a different sender string than what the vendor actually saw. Now both flow throughgetFromAddr(). tsc clean.
v2.87.152026-05-09ProductionFixed
- π¨ **REAL BUG: patient password-reset emails pointed at WordPress, not the Next.js app.**
src/app/api/patient/auth/forgot-password/route.tsreadNEXT_PUBLIC_BASE_URL(does not exist as a configured env var on the GW Vercel project β the configured one isNEXT_PUBLIC_APP_URL) with fallbackhttps://greenwellness.orgβ the apex, still on WordPress + Sucuri WAF until DNS cutover. SoresetUrl = ${BASE_URL}/patient/reset-password?token=...resolved tohttps://greenwellness.org/patient/reset-password?token=...which 404'd at WordPress. Every patient who clicked 'Forgot password' got a dead link. Sister routes (provider/forgot-password+admin/forgot-password) already usedCANONICAL_APP_URLfromlib/app-url.tscorrectly β only the patient route had the divergent env-var-name + wrong-domain combo. Now readsCANONICAL_APP_URL(which has *.vercel.app drift defense + canonical apex fallback). Caught while sweepingprocess.env.X || "patterns. tsc clean."
v2.87.052026-05-09ProductionFixed
- π©Ί PHONE SSoT lift β
prisma/seed.ts4 location seed records had hardcodedphone: "1-888-885-9949"outside thecheck-contact-ssot.mjsgate scope (gate was scanningsrc/only). Nowphone: PHONEfromlib/constants.ts. The 4 location seeds (Spokane / Lynnwood / Olympia / Vancouver) all flow through the SSoT β when the public phone changes, one constant edit propagates to fresh-DB seeds + every public surface. - π‘οΈ
check-contact-ssot.mjsextended to scanprisma/*.tstoo β was scanningsrc/only, missing the seed file. Now scans 447 files (was 446). Pre-push reportsβ check-contact-ssot: 0 hardcoded PHONE/EMAIL across 447 src files. Sister of the v2.86.85 contact-SSoT gate; same shape with broader scan-root. tsc clean.
v2.86.952026-05-09ProductionFixed
- π¨ **Real drift bug caught: TaxSavings calculator used a separate hardcoded
CARD_COST = 175** instead of the PRICING SSoT. Component already importedPRICING(added during v2.86.65 sweep) but the breakeven-weeks math used a parallel constant. Effect: ifPRICING.NEW_IN_PERSONever changes, the calculator silently lies about the breakeven point shown to customers (e.g., bumping the price to $185 would still tell users they break even after 1.X weeks based on the stale $175 β wrong by ~6%). Nowconst CARD_COST = PRICING.NEW_IN_PERSON;. Comment added documenting the fix. tsc clean.
v2.86.852026-05-09ProductionFixed
- π©Ί PHONE + EMAIL SSoT lift β sister of v2.86.55βv2.86.75 PRICING arc. Pre-sweep state had ~17 hardcoded
1-888-885-9949andadmin@greenwellness.orgsites acrosslib/locations-content.ts(4 location phone fields + 3 inline narrative mentions),lib/articles.ts(2 inline mentions in Spokane + Vancouver location articles),app/leave-a-review/page.tsx(mailto: link), and 6 admin/cron route files using the env-fallback patternprocess.env.ADMIN_NOTIFY_EMAIL || "admin@greenwellness.org"(drift hazard same as the v2.81.95 NEXT_PUBLIC_APP_URL sweep). All now flow throughPHONEandEMAILSSoT inlib/constants.ts. Result: 0 hardcoded PHONE/EMAIL acrosssrc/(onlylib/seo.ts:61remains as a documented// commentreference). tsc clean. - π‘οΈ NEW build-gate
scripts/check-contact-ssot.mjsβ pins the PHONE/EMAIL sweep against regression. Sister ofcheck-pricing-ssot(v2.86.75) β same shape, same EXEMPT-with-rationale convention. Allowlist:lib/constants.ts(the SSoT),lib/changelog.ts(history),lib/seo.ts(comment-only mention documenting E.164 conversion). Wired intopackage.jsoncheck:allumbrella +.githooks/pre-push(6/6 gates). Pre-push reportsβ check-contact-ssot: 0 hardcoded PHONE/EMAIL across 446 src files. Why: if the public phone or email ever change, hardcoded sites silently lie β same drift class as PRICING.
v2.86.752026-05-09ProductionAdded
- π‘οΈ NEW build-gate
scripts/check-pricing-ssot.mjsβ pins the v2.86.55βv2.86.65 PRICING SSoT sweep against regression. Scanssrc/**/*.{ts,tsx}for hardcoded$175or$140and exits 1 on offenders. Allowlist:lib/constants.ts(the SSoT) +lib/changelog.ts(historical narrative). Wired intopackage.jsoncheck:allumbrella +.githooks/pre-push(5/5 gates). Sister of the 4 existing structural gates (check-app-url-ssot,check-vercel-cron-dedup,check-cron-heartbeat,check-env-fallback-pattern) β same shape, same EXEMPT-with-rationale convention. Pre-push reportsβ check-pricing-ssot: 0 hardcoded $175/$140 across 446 src files. Why: the v2.86.55 sweep caught a real bug (AI prompt saidreturning $130β $10 off β for many versions). Without an SSoT we couldn't grep for it. Gate ensures the next price change is a single constant edit + a green build.
v2.86.652026-05-09ProductionFixed
- π©Ί PRICING SSoT lift β phase 2 closeout. 17 files swept across the public-facing surface: 4 lib content files (
conditions-content.ts,locations-content.ts,city-condition-content.ts,telehealth-condition-content.tsβ metaDescription / intro / whatToExpect / FAQ-answer fields with embedded prices) + 13 page/component files (telehealth/page.tsx+[city]/page.tsx+[city]/[condition]/page.tsx,learn/[slug]/page.tsx,faq/page.tsx,terms/page.tsx,locations/page.tsx+[city]/page.tsx+[city]/[condition]/page.tsx,conditions/page.tsx+[slug]/page.tsx,pricing/page.tsx,components/sections/TaxSavings.tsx). Three transformation passes via Python scripts: (1) backtick template-literal regions β$${PRICING.NEW_IN_PERSON}/$${PRICING.RETURNING_TELEHEALTH}interpolation, (2) double-quoted JS strings β backtick conversion + interpolation, (3) JSX text nodes>$175<β JSX-expression wrap>{\$${PRICING.NEW_IN_PERSON}\}<. Every changed file got thePRICINGimport at top. **Result: 0 hardcoded $175/$140 acrosssrc/(was ~50+ sites pre-arc).** Closes the price-drift class β next price change is one constant edit. Pre-commit Explore review verified all 89 insertions / 72 deletions. tsc clean.
v2.86.552026-05-09ProductionFixed
- π¨ **REAL BUG (dormant): AI patient-comm prompt quoted wrong renewal price.**
src/app/api/admin/messages/ai-draft/route.tsSMS + email system prompts told the model 'returning $130' β actual price is $140. Bug was dormant becauseAI_DRAFTS_ENABLEDis gated on the Anthropic BAA which hasn't landed. But once Doug flipped that env var, every AI-drafted reply quoting renewal pricing would have been $10 off. Now reads throughPRICING.RETURNING_TELEHEALTHSSoT β drift class closed at the source. Caught while sweeping for hardcoded prices across the codebase. - π©Ί PRICING SSoT lift β phase 1 (high-leverage files). Hardcoded
$175/$140across 6 files now flow throughPRICING.NEW_IN_PERSON/PRICING.RETURNING_TELEHEALTHconstants insrc/lib/constants.ts:lib/faq-data.ts(transparent-pricing FAQ),lib/articles.ts(~20 sites across the article corpus including how-to-get-card / cost-of-evaluation / per-clinic location articles + 5 description-string conversions),api/admin/messages/ai-draft/route.ts(SMS + email AI prompts β the real-bug site),opengraph-image.tsx(default OG stat tile),llms.txt/route.ts(AI-citation surface β keeps ChatGPT/Claude/Perplexity quoting the right price),api/og/route.tsx(OG-image badge default). Phase 2 follow-up will sweep telehealth/conditions/locations pages (~30 more sites). tsc clean.
v2.86.452026-05-09ProductionFixed
- π‘οΈ HIPAA β noindex layouts on 6 PHI-adjacent token surfaces. /checkin/[token], /cancel/[token], /reschedule/[token], /confirm/[token], /intake/[token], and /my-appointments (+ /[token]) now ship
via layout-level metadata. robots.txt'sDisallow: /blocks crawl but does NOT block indexing if Google sees the URL via inbound links β and these tokens travel in confirmation/intake/reminder emails routinely. Patient-portal layout already had this since v2.x; provider + dispensary too. This sweep closes the matching pattern across the 6 patient-side token routes. Sister of the 'privacy noindex' bug class from the 2026-05-08 grind-past-tapped scoreboard. Defense-in-depth β every PHI-adjacent surface is now covered by BOTH robots.txt Disallow + page-level noindex meta. tsc clean./
v2.86.352026-05-09ProductionFixed
- π‘οΈ PHI-leak hardening β round 7: 2 stragglers caught in cross-repo audit. (1)
api/integrations/email/route.ts:86was loggingawait res.text()raw β Resend error bodies commonly echo the recipient email ('InvalidEmailRequest: To: doug@example.com is not a valid email'). Now drains the response (releases connection) and logsstatus=only. Sister of v2.83.75 round 4+5 sweep acrosslib/email.tsPostmark + Resend non-OK branches. (2)api/cron/review-request/route.ts:70logged the fullappt.patient.idUUID β convention acrossworkflow.ts/audit.ts/patient-message-backfill.tsisid.slice(0, 8)prefix. Per Β§164.514(b)(2)(i)(R) Safe Harbor, a unique patient UUID is a 'unique identifying code' disqualifier. Now prefix-only, comment updated. tsc clean.
v2.86.252026-05-09ProductionAdded
- π₯ BreadcrumbList LD on /leave-a-review β last remaining public page without breadcrumb path rendering eligibility. Closes the BreadcrumbList sweep arc 15/15: every public marketing + content page now ships Home βΊ β¦ schema. The LIVE.md note about /resources was stale (/resources is a 308 β /learn since v2.83.75). 2-level path (Home βΊ Leave a Review). tsc clean.
v2.86.152026-05-09ProductionAdded
- π₯ BreadcrumbList LD on /locations/[city] dynamic route β Lynnwood, Spokane, Olympia, Vancouver per-city pages now have 3-level breadcrumb (Home βΊ Locations βΊ City). SERP path rendering eligibility for city-specific MMJ-clinic searches. Sister of /locations index v2.86.05. tsc clean.
v2.86.052026-05-09ProductionAdded
- π₯ BreadcrumbList JSON-LD on /locations β was the last public marketing page without breadcrumb path rendering eligibility. /about, /faq, /learn, /pricing, /providers, /telehealth, /dispensaries already had it. Earns SERP path rendering (Home βΊ Locations) under search results instead of raw URL. Single-line
buildBreadcrumbLdaddition. Sister of the 2026-05-09 BreadcrumbList sweep arc. tsc clean.
v2.85.952026-05-09ProductionFixed
- π¨ check-env-fallback-pattern gate was DEAD β existed at scripts/check-env-fallback-pattern.mjs but never wired into pre-push hook OR check:all OR any npm script. Discovered while grinding past v2.85.55. Cumulative drift exposure: any post-v2.76.9 multi-line nullish-coalesce regression would have been silent. Fix: (a) added
check:env-fallbackscript + appended tocheck:allumbrella; (b) added scripts/check-env-fallback-pattern.mjs to the .githooks/pre-push gate-loop list (now 4/4 instead of 3/3); (c) updated scripts/setup-hooks.sh header comment to reflect the 4-gate structure. Verified: gate runs clean (439 files, 0 offenders). Sister of invfeedback_cron_get_handler_silent_staleclass β gate-not-gating β silent-stale class.
v2.85.752026-05-09ProductionFixed
- π©Ή
/locations/spokane-valley308 redirect β caught pre-launch via patient-facing surface 200 sweep. The clinic NAME on the GW Location row says "GreenWellness Spokane Valley" but thecityfield is just"Spokane", sotoSlug()yieldsspokaneand the sitemap canonicalizes to/locations/spokane(which 200s). Direct-typed/locations/spokane-valley404'd until this commit β patients who type the full city name into the URL bar (or an SEO tool that auto-derives slugs from the location name) would dead-end. Fix: 308 redirect innext.config.tsfrom/locations/spokane-valleyβ/locations/spokane. Preserves SEO juice on the canonical slug + serves the alt-typed traffic. **Doug-action option**: if you'd rather have the alt slug be canonical (more accurate to the actual clinic city), update the DB row'scityfield to"Spokane Valley"and swap source β destination on this redirect line. Same /locations/everett 404 also surfaced in the sweep but is correct (Everett isn't a clinic city). Caught alongside data-integrity probe of all 4 active locations: GreenWellness Spokane Valley (city=Spokane), Lynnwood, Olympia, Vancouver. Plus prod-migrations 20 + 21 applied this session (idempotent backfill: IssuingDoctorHistory unique-open partial index + GbpConnection table β both confirmed present in Neon prod). tsc clean.
v2.85.652026-05-09ProductionAdded
- π° FAQPage JSON-LD on /pricing β earns Google 'People also ask' rich-result eligibility on the 4 pricing FAQs (no-qualify-no-fee guarantee, what's-included, other-fees, insurance-coverage). Pre-fix /pricing had MedicalClinic + OfferCatalog + BreadcrumbList LD but the FAQ section was unstructured. Single-line
buildFaqPageLd(FAQS, { speakableSelector: ['#pricing-faq'] })addition +id='pricing-faq'on the section element. Pricing FAQs are exactly what Google promotes as PAA boxes for cost-related cannabis-card searches. Sister of /faq + /telehealth FAQPage LD. tsc clean.
v2.85.552026-05-09ProductionChanged
- π‘οΈ check-env-fallback-pattern gate tightened with multi-line nullish-coalesce scan β sister of inv v314.005. Pre-fix: gate's 5 single-line regexes (URL/NUM/EMAIL/PLAIN/CONST) couldn't see
process.env.X ??patterns where the fallback string sat on the next line. Inv had 4 latent sites of this class (api/health Β· api/health/ping Β· api/cron/quiz-nurture Β· vmi/assets); GW has 0 currently, but the gate now guards future regressions. Implementation: second pass detectsprocess.env.X ??at line-end + collapses up-to-8 continuation lines + re-runs the 5 regexes against the collapsed form. Skips comment-only continuation lines + dedupes via Set so per-line + multi-line passes don't double-record. Run post-tighten: 439 files scanned, 0 offenders.
v2.85.352026-05-09ProductionAdded
- π€ robots.txt β explicit AI-bot allowlist (18 user-agents) with PHI-safe Disallow rules. Each AI-bot UA (GPTBot, ClaudeBot, PerplexityBot, Google-Extended, Applebot-Extended, etc.) gets the same
phiDisallowrules so PHI surfaces (admin/patient/provider/intake/cancel/checkin/dispensary/my-appointments/reschedule) stay locked even with explicit allow on public marketing pages. Patients researching 'WA telehealth MMJ card' via ChatGPT/Claude/Perplexity now get GW cited from /telehealth, /faq, /providers, /about, /learn, /dispensaries. Sister of glw/scc/Sureel allowlist pattern (~19 UAs each). PHI never reaches model-training corpora β Disallow is respected by every AI bot per robots.txt spec. Single-file edit. tsc clean.
v2.85.252026-05-09ProductionChanged
- π‘οΈ **Pre-launch site copy sweep β no over-promises on services that need vendor BAAs not yet signed.** Doug 2026-05-08 evening: get the website ready to launch THIS WEEK as a Salesforce-feeder bridge, with the warning to confirm we don't promise services gated on Anthropic / Postmark / SES / Twilio / RC voice BAAs. Two specific patterns swept across the 4 public telehealth pages + /about + /locations: (1) **"authorization is emailed the same day"** (6 sites) β **"is issued the same day"** β until Postmark or SES BAA lands, GW emails ride on Resend (no BAA). The same-day authorization claim is real (provider issues it during the visit); the *email-delivery* commitment crosses into PHI-on-non-BAA-vendor territory. Softening to "issued" preserves the real value-prop without binding us to email-delivery method while the vendor BAA is pending. (2) **"HIPAA-protected"** (6 sites: 3 trust pills, 2 marketing-card bodies, 1 locations bullet) β **"Confidential"** / **"HIPAA-aware"** β matches the conservative TrustBar pattern (which already flips to "HIPAA-compliant design" only when
HIPAA_COMPLIANT=trueenv-var attestation is set, gated on all 4 BAAs signed). Pre-fix risk: marketing-truthfulness liability if a patient or regulator interprets "HIPAA-protected" as full BAA-tier coverage on every PHI-touching vendor in the stack today. Internalprovider/training/page.tsxleft as-is (not patient-facing). **Files**: telehealth/page.tsx, telehealth/[city]/page.tsx, telehealth/[city]/[condition]/page.tsx, about/page.tsx, locations/page.tsx. tsc clean.
v2.85.152026-05-09ProductionAdded
- π‘οΈ AI cost-amplification defenses on
/api/admin/messages/ai-draft. Endpoint is currently 503'd viaAI_DRAFTS_ENABLED=false(gates on Anthropic BAA), but when Doug flips it post-BAA, these caps prevent unbounded Anthropic billing from a click-spam (or compromised admin session). **Three new caps**: (1) per-staff rate limit30 drafts/hour/staffviacheckRateLimit("ai-draft:${x-admin-id}"), falls back to IP if header absent β limits cost-amp blast radius; (2)MAX_INBOUND_BODY_BYTES=4000per message β pathological 100KB email body or MMS payload gets sliced + truncated marker before joining the prompt; (3)MAX_THREAD_TOTAL_BYTES=16000cap on assembled thread (last 8 messages, oldest-truncated-first if over budget) plusMAX_PROMPT_BYTES=32000belt-and-suspenders cap on the final context. PluspatientIdvalidation (string + length<100) β defensive against payload shape attacks. Sister of inv defense-class arc (memory pinproject_defense_arc_2026_05_08.md) β same pattern as inv/api/admin/training/draft(max_tokens=4096) +/api/admin/products/generate-description. **GW AI endpoint coverage now complete**:/api/chat(already had MAX_MESSAGES=50, MAX_MESSAGE_BYTES=4KB, MAX_TOTAL_BYTES=100KB, 30/hour/IP rate limit since v2.80.10) +/api/admin/messages/ai-draft(this ship). tsc clean.
v2.85.052026-05-09ProductionFixed
- π©Ί
/admin/launchcron section β fixed permanent false-negative. Pre-fixEXPECTED_CRONShardcoded action names likeCRON_REMINDERS,CRON_NO_SHOW,CRON_REVIEW_REQUESTetc. **ZERO code in the repo writes any of those action strings** β verified via grep. Every cron row was rendering ascaveat("no firing in last 7 days") forever, regardless of whether crons were actually running. Operator-side false signal: looked like every cron was broken when in fact the audit-action-name convention had drifted (or never landed). Post-fix: query selectsaction='CRON_HEARTBEAT'(the actual rows written bywriteCronHeartbeat()v2.84.15+), parsesactor=fromdetail, maps to per-actorcronByActormap. EXPECTED_CRONS now lists all 14 GW Vercel crons (was 8) with cadence-aware staleness budgets matchingEXPECTED_CRON_ACTORSon/api/health: reminders 36h, reminders-2h 12h, no-show 5h, daily 72h, weekly 336h, waitlist 24h. **Acceptance**: rows now showLast fire:. Caveat rows only fire if actor genuinely hasn't fired (cron disabled / scheduling broken). tsc clean.(Xh ago β healthy/past cadence)
v2.84.952026-05-09ProductionAdded
- π‘οΈ Pre-push hook portable + 3 build-gates wired in. NEW
.githooks/pre-push(committed to repo) + NEWscripts/setup-hooks.sh(one-command install viagit config core.hooksPath .githooks). Mirrors invscripts/setup-hooks.shpattern (memory pinfeedback_pre_push_typecheck_gate.md). Hook now runs **5 gates** (was 4 β local-only): (1) changelog guard, (2) footer version-badge guard, (3) SSR-false-in-server-component guard, (4)tsc --noEmit, (5) NEW build-gate umbrella running all 3 SSoT/arc-guard scripts (check-app-url-ssot.mjs+check-vercel-cron-dedup.mjs+check-cron-heartbeat.mjs). **Why portable matters**: pre-push hooks live in.git/hooks/which isn't tracked by git β without a checked-in template + setup script, every fresh clone (Doug's other laptop, future Codespace, parallel sessions) had only the 4-gate hook. Now any clone can runbash scripts/setup-hooks.shonce and inherit the same 5-gate enforcement. **Build-gate umbrella catches**: inlineprocess.env.NEXT_PUBLIC_APP_URL || "fallback"regressions (vercel.app drift, localhost-in-prod-email), duplicate cron paths in vercel.json (silent rejection), cron route added without heartbeat / actor-name drift. NEWpnpm check:allscript chains all 3 for manual verification. **Bypass**:git push --no-verifyfor emergency-only. tsc clean. Verified end-to-end: hook runs in ~16s (15s tsc + 1s gates).
v2.84.852026-05-09ProductionFixed
- π‘οΈ HIPAA: PHI-leak hardening round 7 β 4 sites where
err.messagewas being templated into log lines despite the patient-ID redaction. **Sites:**lib/workflow.ts:132(workflow-log-failed; Prisma errors echo SQL params for the workflow_events insert row, which carries staffUserId + patient context),lib/audit.ts:236(audit-write-failed; Prisma errors echo audit_log row payload),api/admin/patients/[id]/send-renewal/route.ts:60(workflow-log-failed via .catch handler),api/cron/rc-webhook-renew/route.ts:126(DELETE fetch error; can echo request URL + response body, which carries the RC webhook destination address). **Pre-fix** all four extractederr.message(or usedString(err)fallback) into a${msg}template, then logged it alongside the redacted patientIdPrefix / sub.id / resourceId. The structured field redactions were correct, but the err.message itself can leak the rest of the row. **Post-fix:** logerr.name(Prisma class identifier βPrismaClientKnownRequestError,PrismaClientValidationError) plus.code(Prisma error code βP2002,P2003) when present. Forensic trail still flows through the existing audit infrastructure. Sister of v2.84.05 patient-message-backfill + the cross-repo PHI-leak hardening pattern. tsc clean.
v2.84.752026-05-09ProductionAdded
- π‘οΈ NEW arc-guard
scripts/check-cron-heartbeat.mjsβ closes the v2.84.15 β v2.84.65 cron-observability arc against regression. Three-way cross-check: (1) everyvercel.json crons[]path has awriteCronHeartbeat()call in its route.ts (β₯2 calls β canary + completion); (2) every heartbeat actor literal is inEXPECTED_CRON_ACTORSon/api/health/route.ts(else the row is invisible to the staleness probe); (3) everyEXPECTED_CRON_ACTORSentry has a real cron route writing it (else the probe permanently reportslastFiredAt: null + stale: true). Also flags actor-name-vs-directory drift (heartbeat actor must match the route directory + the vercel.json path). Exposed aspnpm check:cron-heartbeat. Sister of inv arc-guard pattern (memory pinfeedback_arc_guard_regression_test_pattern.md). GW has no test runner installed; build-gate is the load-bearing pin shape. **Verified**: 14 vercel.json crons / 14 EXPECTED_CRON_ACTORS entries / 14 cron routes with heartbeat β all aligned. Future agent (or future-Doug) attempting to add a 15th cron without wiring the heartbeat will fail this gate. tsc clean.
v2.84.652026-05-09ProductionAdded
- π‘οΈ NEW build-gate
scripts/check-vercel-cron-dedup.mjs(ported from inv v313.405) β pins the v2.84.45 dedup fix against regression. Parsesvercel.jsonand fails (exits 1) if any cron path appears more than once. Exposed aspnpm check:vercel-cron-dedupfor manual verification. Sister of inv v313.405 same-class gate. Why we need this on GW: appointment-reminder cron/api/cron/reminderswas duplicated β Vercel silently rejected the daily cron block β patient-facing reminders silently un-scheduled. Without this gate a future agent can re-introduce the pattern by pasting two entries with same path + different schedules instead of using the comma-list form (0 16,21 * * *). **Verified**: 14 cron entries, 0 dupes. Memory pin:feedback_vercel_cron_path_dedup.md. tsc clean.
v2.84.552026-05-09ProductionAdded
- π©Ί Cron-observability arc β **CLOSED 14/14**. EXPECTED_CRON_ACTORS on
/api/healthextended from 7 to 14 (parallel session wired heartbeat into the remaining 7: reminders-2h, doh-nudge, new-patient-drip, rc-webhook-renew, review-request, waitlist, slots β including 3 early-return paths in eod-email + 2 in rc-webhook-renew). All 14 GW Vercel crons (pervercel.json crons[]) now writewriteCronHeartbeat()canary-after-auth + completion-with-result. Per-actor staleness budgets calibrated to expected cadence Γ ~3-misses (or 2-misses for the weekly Mon-only / Sun-only schedules)./api/health.cronActorsnow reportstotal: 14, stale:. **Acceptance**: lastFiredAt populates as each cron fires under v2.84.15+ code (within 24h for daily, 4h for waitlist, 2h for reminders-2h, 1h for no-show, 7d for weekly-digest/slots). Initial post-deploy state will show all stale=true with lastFiredAt=null β that's expected (no fires yet under heartbeat code). Sister of inv arc closed 2026-05-07. Closes the cross-repo cron-observability port. tsc clean., details: [...]
v2.84.452026-05-09ProductionFixed
- π©Ί Cron-config dedup:
/api/cron/reminderswas listed TWICE invercel.json(lines 6 + 10 with schedules0 16 * * *and0 21 * * *). Sister of inv v313.205 same-class fix discovered cross-repo same session. Vercel cron config requires unique paths β duplicates cause undefined behavior (silent rejection of one or both, or rejection of the entire cron block depending on validator version). For GW the impact is patient-facing: appointment reminders fire/api/cron/reminders, and a Vercel-side rejection would mean appointment reminder SMS/email don't fire on the affected schedule. Fix: combined into single entry with0 16,21 * * *(cron's comma-separated hour list β fires at both 16:00 and 21:00 UTC daily). 15 β 14 unique. **Investigation recipe** (paste-ready):python3 -c "import json; cfg = json.load(open('vercel.json')); paths = [c['path'] for c in cfg['crons']]; print([p for p in set(paths) if paths.count(p) > 1])". Run before every push that touches vercel.json. tsc clean.
v2.84.252026-05-09ProductionAdded
- π©Ί Cron-observability arc β phase 2+3-partial. Phase 2:
/api/healthnow exposescronActors: { total, stale, details: [{actor, lastFiredAt, staleDays, stale}] }. Per-actor staleness budget = expected-cadence Γ ~3-misses (reminders 1.5d, no-show 0.2d, renewals/daily-briefing/intake-reminder/eod-email 3d, weekly-digest 14d).stale: trueflips whenlastFiredAtexceeds the budget β Doug-action signal (env vars unset / cron disabled / scheduling broken), NOT infrastructure failure (so doesn't 503 the endpoint). Pattern lifted from invstaleActorDetailsshape. Phase 3-partial: extendedwriteCronHeartbeat()from 2 crons (v2.84.15) to 7 β addedrenewals(daily 9 AM PT renewal sequence),daily-briefing(daily 7 AM PT admin summary email),weekly-digest(Mon 8 AM PT practice-health digest),intake-reminder(daily 9 AM PT pre-visit form nudge),eod-email(daily 6 PM PT staff-productivity rollup). Each gets a canary heartbeat after auth + a completion heartbeat with result summary at end. **Remaining for phase 3 (next ship)**: doh-nudge, new-patient-drip, rc-webhook-renew, reminders-2h, review-request, slots, waitlist (7 crons). PHI-safe: result summaries are aggregate counts only. tsc clean. Once phase 3 closes the loop, /admin/launch can render a 'Cron staleness' tile againstcronActors.stale > 0.
v2.84.152026-05-09ProductionAdded
- π©Ί Cron-observability arc β phase 1 of porting from inv (sister of inv arc closed 2026-05-07). NEW
lib/cron-heartbeat.tsexportswriteCronHeartbeat(actor, result?)β writes an AuditLog row withaction: "CRON_HEARTBEAT",staffUserName: "cron",detail: actor=. Reuses the existing AuditLog table so no schema change needed;result= CRON_HEARTBEATextended onto the AuditAction union type. **Wired into 2 starter crons this ship**:/api/cron/reminders(twice-daily appointment reminders) +/api/cron/no-show(hourly stale-appointment marker). Pattern: **canary heartbeat fires immediately afterverifyCronAuth()** (so silent compute failures still surface β the existing 'log when sent>0' pattern can't see failures where compute throws or returns early); **completion heartbeat at the end** with full result summary (overrides the canary). PHI: result summary is aggregate counts only (sent=12 marked=3 skipped=4), never patient identifiers β AuditLog isn't BAA-covered for vendor-side errors. **Phase 2 (next ship)**: extend/api/healthwithstaleActorDetails: [{actor, lastFiredAt, staleDays}]reading from AuditLog whereaction='CRON_HEARTBEAT'group-by detail-actor-prefix. **Phase 3**: extend heartbeat to remaining 12 crons (renewals, daily-briefing, weekly-digest, intake-reminder, eod-email, doh-nudge, new-patient-drip, rc-webhook-renew, reminders-2h, review-request, slots, waitlist). tsc clean.
v2.84.052026-05-09ProductionFixed
- π‘οΈ HIPAA:
lib/patient-message-backfill.ts:61PHI-leak. Pre-fix logged(err as Error).messageβ Prisma errors echo SQL params, which on a patient-message linkage query include patient names + identifiers from the linkage row. Pre-fix kept the patientId redacted (8-char UUID prefix per Β§164.514(b)(2)(i)(R)) but the err.message still leaked sibling PHI from the same row. Post-fix: format-only via err.name. Sister of v2.79.10 + v2.82.40 + v2.82.85 PHI-leak hardening pattern. tsc clean.
v2.83.952026-05-09ProductionChanged
- π§Ή DRY consolidation β
lib/seo.ts+lib/articles.tsno longer duplicate thecanonicalBase()helper; both now importCANONICAL_APP_URLfromlib/app-url.ts(the SSoT introduced in v2.81.95). Pre-fix this file pair declared privatecanonicalBase()helpers with subtly different bodies (seo.ts stripped trailing slash, articles.ts didn't), each implementing the same vercel.app-defense pattern. Bug-class risk: any future drift defense (e.g. add localhost rejection, or harden against*.preview.app.vercel.com) had to be applied in 5 places (seo.ts + articles.ts + sitemap.ts + robots.ts + llms.txt/route.ts) and was guaranteed to be applied in 0β4. Now: trailing-slash strip hoisted intoCANONICAL_APP_URLitself; all 5 consumers reduced to a single-line import.pnpm check:app-url-ssotstill passes (439 files, 0 offenders). tsc clean. Closes the duplicate-helper class entirely.
v2.83.852026-05-09ProductionFixed
- π‘οΈ PHI-leak hardening β round 6: 6 sites where
.catch(console.error)shorthand passed the raw err object straight to console (it stringifies + logs whatever's attached, including request body / response body / Postmark+Resend error envelopes). Sites:api/admin/appointments/manual/route.ts(Γ4 internal-fetch sites β email/sms/salesforce/practicefusion β request body carries patient.email/firstName/id),api/admin/forgot-password/route.ts(sendEmail catch β user.name + reset-link token),api/provider/forgot-password/route.ts(same β provider.name + token),api/appointments/route.tsadmin-notify (patient name + email + phone in scope),api/my-appointments/route.tsmagic-link send (patient.email + portal token),api/webhooks/stripe/route.tsadmin-orphan-notify (email body references PHI via Stripe Dashboard deeplink). Manual-route fetches now share alogFetchErr(label)helper so all 4 sites use the same redaction. Pattern: name-only log. tsc clean. Sister of v2.83.65 round 4+5; closes the.catch(console.error)shorthand class entirely (0 remaining outside changelog narrative).
v2.83.752026-05-09ProductionFixed
- π‘οΈ /resources soft-redirect β 308 permanent. Pre-fix
redirect('/learn')returned HTTP 307 (temporary) β wrong SEO signal for an intentional rename, since 307 tells Google 'short-lived, keep both indexed'. Now usespermanentRedirect('/learn')+force-dynamic(Next 16 quirk: without force-dynamic the redirect prerenders as static 200 + router-push payload). Sister of glw/brands308 fix + scc legacy-URL 308 sweep. Single-file change. tsc clean.
v2.83.652026-05-09ProductionFixed
- π‘οΈ PHI-leak hardening β round 4+5: 26 catch-block + non-OK-response sites across 16 files now redact raw err to
name/statusonly. Sister sweep continuing the v2.79.10 β v2.79.30 β v2.79.50 β v2.79.70 β v2.79.90 β v2.80.10 β v2.80.30 β v2.80.50 β v2.80.70 β v2.80.90 β v2.81.85 β v2.81.90 β v2.82.40 β v2.82.60 β v2.82.85 arc. **Round 4 (route handlers + cron + webhooks):**api/appointments/route.ts(booking + integration paths β Zod-validated patient name/email/phone/DOB + intake answers in scope) Β·api/appointments/reschedule/route.tsΒ·api/admin/appointments/{cancel,manual,reschedule}/route.ts(PF FHIR + waitlist notify err sites β patient name + appointment payload) Β·api/admin/messages/{ai-draft,send}/route.ts(ai-draft prompt context + outbound attachment persist) Β·api/admin/outreach/route.ts(patient list + send-results) Β·api/integrations/{email,practicefusion,salesforce}/route.ts(PF FHIR demographics + SF Lead payload) Β·api/my-appointments/route.ts(portal-link requester email) Β·api/availability/route.ts(public β redacted for hygiene) Β·api/webhooks/postmark/inbound-email/route.ts(Γ2 β attachment upload + auto-ack send; rawName can reveal medical context like 'lab-results.pdf') Β·api/webhooks/ringcentral/{calls,sms}/route.ts(persist err carries phone + recording URL + body text) Β·api/cron/review-request/route.ts(Γ2 β siteSettings lookup + per-patient SMS send) Β·api/provider/bulk-approve/route.ts(cert + appointment payload β id stays loggable, payload doesn't) Β·api/intake/[token]/documents/route.ts(Blob + filename) Β·api/admin/documents/[id]/route.ts(Blob delete URL). **Round 5 (lib non-OK-response branches):**lib/email.tsPostmark + Resend!res.okbranches were loggingawait res.text()raw β vendor error response bodies commonly echo recipient email + sender details ('InvalidEmailRequest: To: doug@example.com is not a valid email'); now status-only.lib/ringcentral.tstoken-exchange + SMS-send!res.okbranches same pattern; SMS error envelopes echo from/to E.164 + body text. **Pattern**:const name = err instanceof Error ? err.name : "unknown"; console.error([] : ${name} ). For HTTP non-OK branches:void res.text().catch(() => ""); console.error([] error: status=${res.status} )(drains the response so the connection releases, but doesn't log the body). Vercel function logs aren't BAA-covered; raw err / response body in logs = HIPAA breach risk in patient-care context. tsc clean across all 23 file edits.
v2.83.552026-05-08ProductionAdded
- π₯ BreadcrumbList JSON-LD on 4 missing pages: /telehealth, /changelog, /privacy, /terms. Pre-fix the seo helper
buildBreadcrumbLdwas wired on /about, /faq, /learn, /providers, /dispensaries, /refer but missed these 4 surfaces. Earns SERP path rendering (Home βΊ Telehealth, Home βΊ HIPAA Privacy Notice, etc.) β 1-2% CTR lift per Search Console A/Bs. /resources still has zero JSON-LD; separate ship to add the full WebPage shape there. Sister of glw v7.385/v7.445 + scc v8.525/v8.585 BreadcrumbList sweeps. tsc clean.
v2.83.452026-05-08ProductionChanged
- π― Softphone polish β Bundle B2 closes the
SOFTPHONE_POLISH.mdpunch list. Three additions, all desktop-targeted (mobile uses bottom-sheet from B1 where these don't apply): **drag** β header is now a pointer-captured drag handle.pointerdownrecords start position + base offset,pointermoveupdates{dx, dy},pointerupreleases capture. Iframe doesn't steal events because the parent has pointer capture for the duration. Skipped when click target is one of the chrome buttons (minimize/close) so they don't double-fire as drag-start. Cursor changes tograb/grabbing. **Position memory** β{state, dx, dy}persists tolocalStorage["rc-softphone-pos"]on change; hydrated post-mount in auseEffect(SSR-safe), with shape validation so a corrupted entry can't crash the dialer. The next page-load reopens the widget where staff left it. **β\ keyboard toggle** βCmd/Ctrl + \toggles open β hidden globally. Skipped when an input/textarea/contenteditable has focus so staff typing in a patient note can use\literally. Avoids βK (AdminCmdK) and the VS-Code-conditioned βP/β./βB. Punch list closed: 5/5 items shipped (A1βA3 v2.83.05, B1 v2.83.35, B2 v2.83.45). tsc clean.
v2.83.352026-05-08ProductionChanged
- π± Softphone polish β Bundle B1 (iPad / small-laptop responsiveness from
SOFTPHONE_POLISH.md). Pre-fix: hardcodedh-[600px] w-[360px]overflowed ~768px viewports, ignoredsafe-area-inset-bottom(iPad home-bar overlapped the dialer keypad), and usedvhwhich clipped on iOS Safari URL-bar collapse. **Fix:** belowsm:(β€640px, phones), open state is now a full-width bottom sheet (inset-x-0 bottom-0 h-[85dvh] rounded-t-2xl) with a tap-to-dismiss backdrop β same modal pattern AdminCmdK uses.sm:and up keeps the floating panel:w-[min(360px,calc(100vw-2rem))] h-[min(600px,calc(100dvh-6rem))]so the widget never clips on small laptops.dvh(dynamic viewport height) accounts for iOS Safari URL-bar expansion;vhwould overflow when the URL bar collapses. Bottom offset usesbottom-[max(1rem,env(safe-area-inset-bottom))]so iPad home-bar doesn't sit on the dialer. Minimized state remains a docked pill at bottom-right on every viewport. B2 (drag + position memory + β\ toggle) ships next.
v2.83.252026-05-08ProductionAdded
- π GBP performance dashboard at
/admin/marketing/gbp-performanceβ port from inv. Live read of Google's Business Profile Performance API for the practice's GBP listing. 4 headline tiles (impressions / call clicks / direction requests / website clicks) with 30-day totals + WoW % trend chips, server-rendered SVG sparklines (no JS / no chart deps), per-source impressions split (desktop/mobile Γ search/maps) with progress bars. Single Performance API call covers all 8 metrics viafetchMultiDailyMetricsTimeSeriesβ no fan-out. Server Component withforce-dynamic+revalidate=0so each visit pulls fresh. Admin/Manager gated viaverifyAdminSession; lower roles redirect. Inline not-connected fallback (env vars / no OAuth / no location resource) shows the actual gap + deep-links to /admin/integrations/gbp. **Lib helpers** added tosrc/lib/gbp.ts:sumMetric()+splitForTrend()(the perf fetcher itself shipped with v2.81.50). **HIPAA scope:** GBP metrics are aggregate counts (impressions / clicks / direction requests) β NOT PHI. Reviews CAN contain patient-identifying detail and don't surface here; separate /admin/marketing/reviews surface deferred. **Palette adapted** to GW's #0f2744 navy + #2d6a4f green (vs inv's zinc dark). tsc clean.
v2.83.052026-05-08ProductionChanged
- π¨ Softphone polish β Bundle A (3 fixes from
SOFTPHONE_POLISH.mdUX-expert review). **A1 Header chrome matches admin language**: white header withborder-b border-[#f0f0ec], navy#0f2744title,rounded-xl shadow-lg ring-1 ring-foreground/10chrome β replaces the third-party-widget-pasted-in look (solid emerald-700 header + amber/rose pills). Pills now use shadcnBadgeprimitives (destructivefor Incoming,outlinefor Sign-in) β same components AdminCmdK uses.RcPresenceDotreused inline as the at-a-glance status indicator (same component AdminNav uses) so signed-in/ringing/on-call state is consistent across the shell. **A2 Always-mount iframe (kills cold-boot on inbound ring)**: pre-fixstate === "open" &&meant a minimized widget had no live RC session β incoming calls woke a cold iframe (OAuth re-handshake, missed audio, race againstrc-call-ring-notify). Post-fix iframe always mounts onceclientIdis present;hiddenattr toggles visibility, so the RC socket stays warm + ring-notify fires reliably. **A3 Incoming-call a11y + visual urgency**: addedso screen-reader users hear inbound rings, andIncoming callanimate-pulse ring-2 ring-emerald-400on the container so glance-away-staff don't miss the rose Badge. Bundles B1 (iPad responsiveness) + B2 (drag + position memory + β\ toggle) tracked in punch list, ship next.
v2.82.952026-05-08ProductionFixed
- π Admin shell: gate auth-only widgets on a verified session, not on the fallback
role. Pre-fixadmin/layout.tsxdefaulted role toSCHEDULERwhen no session existed (so AdminNav wouldn't crash mid-redirect), then rendered,,,,,, andbased onrole !== "BOOKKEEPER"β which evaluates true for the unauthenticated SCHEDULER fallback. Most visible symptom: the RingCentral softphone iframe booted OAuth on the login page itself before staff had authenticated to our admin shell. Fix: deriveconst authed = !!sessionand gate every auth-only widget onauthed && β¦. Login page no longer mounts softphone, command palette, preflight banners, heartbeat, or whats-new modal. Sister of the role-fallback hardening pattern across admin surfaces. - π¨ NEW
SOFTPHONE_POLISH.mdpunch list β UX-expert review ofRcSoftphone.tsxproduced a 5-item polish backlog (header chrome match, always-mount iframe to kill cold-boot on inbound ring, incoming-call a11y/pulse, iPad responsiveness, drag + position memory + keyboard toggle). Bundle A (3Γ S) ships next.
v2.82.852026-05-08ProductionFixed
- π‘οΈ HIPAA: format-only error logging across cancel + waitlist HTTP routes. Sister of v2.79.10 + v2.82.40 + v2.82.60 PHI-leak hardening pattern. Fixed 4 sites:
api/appointments/cancel/route.ts:97(PF cancel via cancelFhirAppointment β payload includes pfApptId + slot/patient ref),api/appointments/cancel/route.ts:128(notifyWaitlist catch β patient email + slot details),api/appointments/cancel/route.ts:144(route-level try/catch β wraps Stripe SDK + FHIR + DB errors carrying cancelToken/patient PHI),api/waitlist/route.ts:69(signup catch β DB error wraps inbound payload: name/email/phone/conditions). Pre-fix all four passed rawerrtoconsole.error, which Vercel logs at full structure. Vercel logs are NOT BAA-covered β patient PHI in logs = HIPAA breach. Post-fix:const reason = err instanceof Error ? err.name : "unknown"; console.error(\[source] failed: \${reason}\)β error class only, no payload. Forensic trail still available vialogCriticalError/audit which write to BAA-covered Postgres.
v2.82.802026-05-08ProductionAdded
- π‘οΈ NEW build-gate:
scripts/check-app-url-ssot.mjsβ pins the v2.81.95 β v2.82.20 SSoT migration against regression. Scanssrc/**/*.{ts,tsx}for the inline patternprocess.env.NEXT_PUBLIC_APP_URL || "and fails (exits 1) if any new offender appears. Why we need this: each fallback we found during the sweep was a real bug class (canonical-apex drift, localhost-in-prod email links, hardcoded" green-wellness-gamma.vercel.app, wrong subdomaingreenwellness.orgwithoutflow.). The SSoT (lib/app-url.ts β CANONICAL_APP_URL) centralizes the *.vercel.app drift defense + canonical apex fallback. Without this gate a future agent (or future-Doug) could quietly re-introduce the inline pattern and resurrect any of those bugs. **Allowlist** (3 entries with documented rationale):lib/app-url.ts(the SSoT itself),lib/changelog.ts(historical narrative),app/admin/launch/page.tsx(env-status dashboard intentionally surfaces raw env-var). **Wired**:pnpm check:app-url-ssot(manual run for now; can wire into pre-push hook on next iteration if Doug wants strict CI enforcement). **Modes**: default strict,--warnfor warn-only. Verified:0 offenders across 438 src files. tsc clean.
v2.82.602026-05-08ProductionFixed
- π©Ή Stripe webhook admin-email error PHI-hardening β 2 more sites within
webhooks/stripe/route.tsused.catch(console.error)shorthand which passes the raw err object straight to console. The two admin-notification email sends (payment-failed admin alert + payment-succeeded-no-appointment escalation) both swallow + log raw err. sendEmail errors can include Postmark/Resend request bodies with admin-email content β that content references patient PII via Stripe Dashboard links. Now: format-only log (err.nameonly). Sister of v2.82.40 (which fixed the 2 primary Stripe error sites β sig-verify + intent-create); this closes the 2 ancillary admin-email-send sites within the same webhook route. tsc clean.
v2.82.402026-05-08ProductionFixed
- π©Ή Stripe payment-processor error PHI-hardening (Doug direction: 'grind on payment procerror'). Two sites:
src/app/api/stripe/intent/route.ts:75logged rawerrfromstripe.paymentIntents.createβ Stripe SDK errors include the request URL + request_id + idempotency_key (Stripe API context).src/app/api/webhooks/stripe/route.ts:23logged rawerrfromstripe.webhooks.constructEventon signature-verification failure β the err can include the raw request body, which IS a real Stripe event payload (with customer.email + billing.name + receipt.email = PHI in our patient context) when sig-verify fails mid-webhook-secret-rotation. Vercel logs aren't BAA-covered. Now: format-only logging (err.nameonly). Sister of v2.79.10 + v2.79.30 + v2.79.50 PII/PHI-leak hardening pattern. tsc clean.
v2.82.202026-05-08ProductionFixed
- π‘οΈπ CANONICAL_APP_URL SSoT migration β full sweep follow-up to v2.81.95. Pre-fix 38+ surfaces still inlined
process.env.NEXT_PUBLIC_APP_URL || ...with no*.vercel.apprejection. Vercel.app drift would have silently corrupted: (a) every public page+ OpenGraph tags (telehealth/learn/locations/conditions/providers/about/pricing/confirm), (b) admin email-link-builder routes (waitlist, patient/provider portal-link emails, forgot-password, daily-briefing send + cron, intake-reminder, new-patient-drip, eod-email, my-appointments ICS), (c) HIPAA-bearing API routes (admin appointments approve/cancel/manual/reschedule/resend-confirmation/status/remind, integrations/email, my-appointments, webhooks/twilio, cron/waitlist + reminders + review-request), (d) lib helpers (unsubscribe-token, locations-content, conditions-content), (e) admin patient-portal page + scheduling StepConfirmation client component. **Critical fixes** in this batch: 5 routes had|| "http://localhost:3000"fallback (would have shipped localhost in prod email links if env unset). 2 routes had|| "https://greenwellness.org"fallback (without theflow.subdomain β wrong canonical). 38 of 38 swept this commit; only deferral:admin/launch/page.tsx:548env-status dashboard line β fallback string is intentionally informational ("NOT SET β uses fallback https://flow.greenwellness.org") to surface env-var status to admins. **Cumulative cross-repo arc**: scc v8.415 + glw v7.275 + inv v303.605 + inv v305.005 + inv v305.805 + GW v2.81.95 + GW v2.82.10 + GW v2.82.20 β 50+ surfaces sealed across 4 repos. Migration done via reusable Python script with conservative regex (only matches the exact inline-fallback pattern). tsc clean. Sister landing alongside parallel-agent v2.82.10 rc-webhook-renew migration in same commit.
v2.82.102026-05-08ProductionFixed
- π¦
cron/rc-webhook-renewβ switched from rawprocess.env.NEXT_PUBLIC_APP_URL || "https://green-wellness-gamma.vercel.app"fallback to the newCANONICAL_APP_URLSSoT helper (sister-fix tocron/eod-emailwhich already adopted the helper). Pre-fix: if env-var was unset OR set to a*.vercel.appvalue, RC would re-register subscriptions pointing at the stalegreen-wellness-gamma.vercel.appdeployment URL β webhook events would deliver to a 404 endpoint once the gamma alias decommissions post-DNS-cutover. Real-world risk: silent loss of inbound RC SMS replies + missed-call auto-text triggers. Now:CANONICAL_APP_URLrejects vercel.app drift in env-var + falls back toflow.greenwellness.org. Single line change + 1-line import. tsc clean.
v2.81.952026-05-08ProductionFixed
- π‘οΈπ NEW
lib/app-url.tsSSoT (CANONICAL_APP_URL) + 8-surface migration β sister of cross-repo arc landing on inv (v303.605/v305.005/v305.805) + scc/glw welcome-email arc. Pre-fixprocess.env.NEXT_PUBLIC_APP_URL || "https://flow.greenwellness.org"(or|| "http://localhost:3000"on API routes) was inlined at 30+ sites with no*.vercel.apprejection βcanonicalBase()defense pattern existed in only 3 places (sitemap.ts + robots.ts + llms.txt/route.ts), and even those were duplicated copies of the same helper. **Why this matters most for GW**: HIPAA-bearing email surfaces. If the env var ever drifted to a Vercel preview hostname (the same v45.205 incident that hit inv on Seattle), patient-facing cancel/reschedule deep-links + admin-side referral-link copy + patient self-cancel URL on the admin page would all advertise the wrong host β and those URLs ride in PHI emails. **Migrated this commit (8 surfaces)**: (1)app/layout.tsxpage-canonical/OG metadata APP_URL β every public page's+og:urletc. (2)app/robots.tsβ collapsed local canonicalBase() into SSoT import. (3)app/sitemap.tsβ same. (4)app/llms.txt/route.tsβ same (AI-citation surface). (5)api/appointments/cancel/route.tsβ patient cancellation-confirmation email rebooking link (was|| 'http://localhost:3000'β would have left localhost in prod email if env unset). (6)api/appointments/reschedule/route.tsβ patient reschedule-confirmation email (same localhost-fallback drift). (7)admin/patients/[id]/_components/CopyReferralLinkButton.tsxβ admin-copies referral link to clipboard for patient share. (8)admin/appointments/[id]/page.tsxβ patient self-cancel link displayed to admin (Γ2 sites in same file). **Deferred (~22 sites)**: remainingprocess.env.NEXT_PUBLIC_APP_URL || ...consumers (waitlist routes, daily-briefing, intake-reminder, eod-email crons, etc.) β same drift class but lower-frequency surfaces; will migrate in follow-up commits to keep this commit's blast radius reviewable. tsc clean.
v2.81.902026-05-08ProductionFixed
- π¦
notifyWaitlist()check-then-write race + perma-stall queue head bug. Pre-fix:findFirst({notifiedAt: null})then laterupdate({notifiedAt: now})after sending. **Race**: two concurrent slot-frees both findFirst the same head-of-queue entry, both pass the unsub gate, bothsendEmail, entry receives **two emails**, both update notifiedAt (idempotent but cosmetic β damage already done). **Stall**:if (sent)only updated notifiedAt on successful send β a perma-bouncing email at the head of the queue would block position-2+ patients FOREVER. **Fix**:updateMany({where: {id, notifiedAt: null}})for atomic claim at function entry; on race-lossclaimed.count === 0and we silently bail (winner sends, loser steps back). Failed sends now leave the claim in place + log format-only error to Vercel logs (no PHI; just entry-id-prefix). Behavior trade: failed-send no longer auto-retries β Doug-ops follow-up via /admin/errors if a real send issue happens. Both outcomes are better than pre-fix: race silently double-emails, stall silently blocks queue. Sister of v2.74.28 + v2.74.31 + v2.74.5 CAN-SPAM gates that established the unsub-mark pattern. tsc clean.
v2.81.702026-05-08ProductionFixed
- π‘οΈ JSON-LD
escape β 19 customer-facing pages useddangerouslySetInnerHTML={{ __html: JSON.stringify(...) }}straight without escaping<to\u003c. Threat model: most JSON-LD inputs come from controlled sources (FAQ items in code, provider rows from DB) but provider bio + title fields are admin-edited free text. Compromised admin β edit bio withβ XSS on every customer-facing provider page that renders that JSON-LD. **Fix**: newlib/json-ld-safe.tsexportssafeJsonLd(data)that callsJSON.stringify(data).replace(/. JSON parsers re-decode\u003cback to<so Google + AI search engines see identical structured data, but the HTML parser never sees a literal<so the script tag stays intact. **Files swept (19)**: src/app/page.tsx (2 β Organization + WebSite) Β· src/app/telehealth/page.tsx (2 β service + faq) Β· src/app/telehealth/[city]/page.tsx (3) Β· src/app/telehealth/[city]/[condition]/page.tsx (4) Β· src/app/learn/page.tsx (2) Β· src/app/learn/[slug]/page.tsx (2) Β· src/app/conditions/[slug]/page.tsx + others Β· src/components/home/HomeContent.tsx (FAQ). All routed through the new helper. tsc clean.
v2.81.502026-05-08ProductionAdded
- π©Ή Land the actual GBP integration files β v2.81.10 changelog entry promised them, v2.81.30 patched the AuditAction union for forward-compat, but the source files (lib/gbp.ts + 4 route files + admin UI page + Prisma model + prod-migration-21.sql) hadn't actually landed in either commit. This is the file-landing ship that closes the gap between what the changelog described and what's in the repo. Same pattern as inv v289.405 earlier today (changelog promised entity-sweep, file edit landed in a follow-up commit). Files:
src/lib/gbp.ts(~280 LOC OAuth + Reviews + Performance API helpers),src/app/api/auth/gbp/start/route.ts(admin-gated CSRF state cookie redirect),src/app/api/auth/gbp/callback/route.ts(state verify + token exchange + auto-discovery + audit),src/app/api/admin/integrations/gbp/disconnect/route.ts(POST ADMIN-only delete + audit),src/app/admin/integrations/gbp/page.tsx(setup-status checklist + Connect/Disconnect UI). Prisma schema addsGbpConnectionsingleton model.prod-migration-21.sqlcreates the table (idempotent CREATE TABLE IF NOT EXISTS). Bonus:scripts/set-admin-password.mjsfrom the earlier-in-session emergency lockout ride-along (Doug got locked out of GW because no email provider was configured β script bypasses email for admin password sets via direct DB UPDATE, audit-logged asADMIN_PASSWORD_RESET_CLI). Removed the duplicateGBP_OAUTH_CONNECTED/GBP_OAUTH_DISCONNECTEDentries fromsrc/lib/audit.tsthat v2.81.30 added β they're now in one place. tsc clean.
v2.81.302026-05-08ProductionFixed
- π©Ή tsc compile error on origin/main β
GBP_OAUTH_CONNECTED+GBP_OAUTH_DISCONNECTEDAuditAction values used in/api/auth/gbp/callback/route.ts:119+/api/admin/integrations/gbp/disconnect/route.ts:27but never added to theAuditActionunion insrc/lib/audit.ts. v2.81.10 changelog entry claimed they were added but the enum extension was missed in the actual ship. Caught grinding viatsc --noEmit. Adding both to the union βaudit()calls now type-check + prod build no longer relies on tsc-skipped path. Vercel build was tolerating this because Next.js doesn't fail builds on tsc errors by default (would needtypescript.ignoreBuildErrors: falsestrict gate). tsc clean post-fix.
v2.81.102026-05-08ProductionAdded
- π Google Business Profile integration β OAuth + Performance API + Reviews readers (port from inv-app, adapted for GW). Doug 2026-05-08 enabled the Business Profile Performance API in Google Cloud Console + created OAuth client
GreenWellnessin thegreen-health-analyticsproject. Vercel env varsGBP_OAUTH_CLIENT_ID+GBP_OAUTH_CLIENT_SECRETset on the GW project (Production scope). Authorized redirect URI added:https://flow.greenwellness.org/api/auth/gbp/callback. **What's wired this ship:** (1)prisma/schema.prisma+prod-migration-21.sqladd a singletonGbpConnectiontable (refreshToken + locationResource + connectedAt + connectedBy{UserId,Name} + lastRefreshedAt). One row per practice. (2)src/lib/gbp.ts(~280 LOC) β OAuth helpers (consent URL builder, code-exchange, refreshβaccess trade), location-resource auto-discovery (account list β first-account locations), reviews list (paginated, returns raw GBP review objects), and **Performance API daily-metrics fetcher** (fetchDailyMetrics) supporting 11 metric types β search impressions desktop/mobile Γ maps/search, conversations, direction requests, call clicks, website clicks, bookings, food orders, food menu clicks. The Performance fetcher hitsbusinessprofileperformance.googleapis.com/v1/{location}:fetchMultiDailyMetricsTimeSeriesdirectly β no Google SDK dependency. (3)/api/auth/gbp/startβ admin/manager-gated entry point, generates CSRF state cookie (10 min TTL), redirects to Google consent URL. (4)/api/auth/gbp/callbackβ verifies state cookie matches URL state param, exchanges code for refresh+access tokens, attempts location auto-discovery, upserts the singleton, audits via newGBP_OAUTH_CONNECTEDAuditAction. (5)/api/admin/integrations/gbp/disconnectβ POST, ADMIN-only, deletes the singleton + audits via newGBP_OAUTH_DISCONNECTEDAuditAction. (6)/admin/integrations/gbp/page.tsxβ admin UI: setup-status checklist (env vars / connection / location resource / last refresh) + Connect or Disconnect button + HIPAA note about review-content sensitivity. **HIPAA scope:** GBP business data isn't PHI in Google's hands but reviews can contain patient-identifying detail; integration only surfaces reviews to ADMIN/MANAGER sessions and does NOT persist review text by default. Refresh token stored plain (non-PHI; no encryption helper yet β follow-up if Doug wants Bring-Your-Own-Key for OAuth tokens). **Doug-actions remaining:** (a) apply prod-migration-21.sql (regular Vercel deploy or manual node script in the migration header). (b) sign in to /admin/integrations/gbp as ADMIN β click Connect β grant access on Google's consent screen. tsc clean.
v2.80.902026-05-08ProductionFixed
- π¦ TCPA + CAN-SPAM consent leak on
/api/appointments/rescheduleβ pre-fix sent confirmation email toupdated.patient.emailregardless ofpatient.emailUnsubscribedAND sent SMS gated only onappt.smsConsent(booking-time snapshot) without checking currentpatient.smsConsent(which flips to false when patient texts STOP after booking). Sister of v2.73.21 4-send-paths fix that established the pattern: always honor BOTH appointment-level + current patient-level flags. Pre-fix scenario: patient books, then texts STOP / clicks unsubscribe link β reschedules later β STILL gets the confirmation SMS + email. Now: SMS gated onappt.smsConsent && patient.smsConsent; email gated on!patient.emailUnsubscribed. Sister-checked the other email/SMS send sites β most transactional confirmations (cancel, status updates) fall under CAN-SPAM transactional exception, but the reschedule path was an outlier where the v2.73.21 fix never landed. tsc clean (the gbp/callback tsc error is pre-existing on origin/main, not my change).
v2.80.702026-05-08ProductionFixed
- π¨ **Sister fix to v2.80.50** β same Stripe-refund-error-swallow pattern on the OTHER 2 refund call sites:
src/app/api/appointments/cancel/route.ts(patient self-cancel) +src/app/api/admin/appointments/cancel/route.ts(admin-initiated cancel). Pre-fix both didawait stripe.refunds.create(...).catch(err => console.error)βawaitis present but the.catchswallows Stripe-side rejections (already-refunded / unknown intent / network failure / API down). The follow-on email + JSON response BOTH claimed 'refunded' regardless of actual Stripe outcome β patient gets a refund-confirmation email for a refund that never happened. **Fix**: explicit try/catch withrefundSucceededboolean; email'srefundNoteshows 'we'll process manually within 1 business day' on failure; JSON returns bothrefunded:ANDrefundPending:flags. Format-only console.error logging (Stripe SDK errors carry payment-intent IDs + user metadata = PHI in patient context). All 3 GW Stripe-refund call sites now consistent. tsc clean.
v2.80.502026-05-08ProductionFixed
- π¨ **CRITICAL: SLOT_TAKEN refund was fire-and-forget β patient could be charged + told 'refunded' without refund actually completing.**
src/app/api/appointments/route.ts:214hadstripe.refunds.create(...).catch(...)(noawait) followed by an immediatereturn NextResponse.json({error: '...your payment has been refunded.'}). On Vercel Fluid Compute the function instance may be reaped before the refund POST completes β leaving the patient with a 'refunded' message but NO actual refund processed at Stripe. Worst case: charged for a slot they didn't get + told they were refunded + Doug only finds out via Stripe dashboard reconciliation weeks later. **Fix**: await the refund inside try/catch. On success, original 'refunded' message ships. On Stripe rejection (already-refunded / unknown intent / network failure), patient gets a 'we were unable to auto-refund β we'll process it manually within 1 business day' message + console.error logs format-only error name (no PHI in logs β Stripe SDK errors carry payment-intent ID + user metadata that are PHI in our patient context). Same pattern as the silent-write defense arc on inv (memoryfeedback_silent_write_defense_arc) ported to GW's highest-stakes payment path. tsc clean. **Likelihood-of-trigger**: low (SLOT_TAKEN only fires when two patients book the same slot in the same ~5s window) but **stakes high** (real $$ + PHI + patient trust). Pre-existing race-condition path latent since the slot-locking ship; never observed in prod per /admin/errors but unbounded silent-fail surface.
v2.80.302026-05-08ProductionFixed
- π§± Server-only wall β
lib/migration-drift.tswas missing theimport 'server-only'marker even though it imports@/lib/db(Prisma client + connection-string env vars). Defense-in-depth per cross-repo pattern (sister to inv v301.805 sweep): lib files mixing types + DB fns leak the Prisma driver to clients if accidentally imported by a Client Component. Pre-add grep verified zero existing client-component importers β onlysrc/app/api/health/route.ts(server) imports it. Convention now: server-only marker present on every lib file that pulls @/lib/db. Single-line addition. tsc clean.
v2.80.102026-05-08ProductionFixed
- π‘οΈ /api/chat β input bounds added (max 50 messages, 4KB per message, 100KB total) to prevent AI-bill DOS via the public chatbot. Pre-fix the messages array was accepted unbounded β attacker could send 1000+ messages or a single 10MB message and each token streams to Anthropic at our cost. Per-IP rate limit (30/hour) caps RATE but not SIZE. Now: 413 Payload Too Large + helpful error copy when caps tripped. Caps: MAX_MESSAGES=50, MAX_MESSAGE_BYTES=4_000, MAX_TOTAL_BYTES=100_000. Helper
bodyByteLength()traverses both legacycontentfield + UIMessagepartsarray. PHI-leak risk on this endpoint is low (system prompt + ChatWidget disclaimer tell patients not to share health details), but AI-bill DOS via unbounded payloads is real. Same defense class as inv staff login input bounds. tsc clean.
v2.79.902026-05-08ProductionFixed
- π
src/app/api/cron/reminders/route.tsdocstring drift β header said 'Runs daily at 16:00 UTC' (singular) but vercel.json schedules the route TWICE (16:00 UTC + 21:00 UTC). The 21:00 fire was added by commit 44577b9 ('afternoon reminders trigger for 2h SMS coverage') but the docstring was never updated. Caught grinding through cron-schedule cross-reference (path / route file / docstring symmetry check). Updated to clarify: morning fire (16:00 UTC = 9 AM PT) catches late-morning 2h slots, afternoon fire (21:00 UTC = 2 PM PT) catches late-afternoon 2h slots, both idempotent via WorkflowEvent. Documentation-only fix; no code change. Why this matters: next maintainer reading 'runs daily' would be surprised by the second invocation showing up in /admin/launch + audit logs.
v2.79.702026-05-08ProductionFixed
- π§± server-only wall on 3 GW lib files importing
@/lib/db(Prisma client + connection-string env).lib/home-server-data.tsis imported by 2 client components (HomeContent.tsx + Hero.tsx) but only viaimport type {...}— type imports are erased before bundling so the runtime DB driver never reaches the client. Theserver-onlypackage adds an explicit runtime sentinel that throws if a future regression imports a VALUE (not just type) from a client component, preventing silent leak of Prisma client + DB URL into the browser bundle. Also added tolib/migration-drift.ts(called only from /api/health) +lib/daily-briefing.ts(called only from cron + admin server route). Sister of inv v300.005 (3 lib files swept). Defensive only — verified zero existing client-component VALUE imports across all 3 files. tsc clean.
v2.79.502026-05-08ProductionFixed
- π¦ canonicalBase() defense extended to
lib/seo.tsSITE_URL +webhooks/stripe/route.tsadmin email links (2 sites). Pre-fixlib/seo.tsSITE_URL fell back toprocess.env.NEXT_PUBLIC_APP_URL || "https://flow.greenwellness.org"(env var wins) β same shape as the v2.78.90 sitemap.ts/robots.ts/llms.txt/articles.ts fix. SITE_URL flows into every page's canonical link + OG metadata + JSON-LD; if env points at vercel.app, every page advertises a non-canonical hostname to Google + AI search. Stripe webhook admin-failure email body had two raw${process.env.NEXT_PUBLIC_APP_URL ?? ""}interpolations to/admin/patients+/admin/appointments/newβ admin clicking from a vercel.app email lands on non-canonical host. Both now use the canonicalBase() pattern (env wins ONLY if not vercel.app). Same pattern applied across all 6 surfaces now (sitemap/robots/llms/articles/seo/stripe-webhook). tsc clean.
v2.79.302026-05-08ProductionFixed
- π©Ί PHI-leak hardening β 3 more catch blocks across
lib/twilio.ts(Twilio outbound SMS),lib/ringcentral.ts(RC outbound SMS),lib/ratelimit.ts(Upstash). Pre-fix all 3 logged the rawerrobject on failure: Twilio + RC SDK errors carry the request payload (from/toE.164 phone numbers + body text), which are HIPAA-protected when the patient is in a medical-care context; ratelimit was logging thekeyvalue (often phone-number / patient-id / IP-tied). Vercel logs aren't BAA-covered, so any of these in a logged error = HIPAA breach risk. Now: format-only logs (err.name/ status only β[SMS] Twilio error: TimeoutError status=429). Sister of v2.79.10 (which fixed email.ts + workflow.ts during pre-commit Explore review). Same audit pattern in inv (extensive PII-guard memoryfeedback_silent_write_defense_arc). Continues the catch up from the per-route case-by-case sweep that's been running for weeks. tsc clean.
v2.79.102026-05-08ProductionFixed
- β±οΈ External-fetch timeout hardening β 6 lib files / 12 fetch sites get AbortSignal.timeout fallback. Pre-fix: lib/practicefusion.ts (3 β EHR FHIR API), lib/salesforce.ts (2 β CRM lead push), lib/email.ts (2 β Postmark + Resend), lib/ringcentral.ts (3 β phone/SMS), lib/ratelimit.ts (1 β Upstash redis), lib/workflow.ts (1 β SF task creation) all called global fetch() with no per-request timeout. On a HIPAA telehealth platform, hung fetches block patient flows: a stalled Postmark connection during booking confirmation leaves the patient seeing 'still processing' indefinitely. Vercel's 300s function-timeout eventually kills the request but: (a) leaves no graceful error path, (b) on Fluid Compute a hung fetch ties up a function instance shared across other concurrent requests, (c) wastes 300s of Active CPU billing watching a hung TCP. Per-call timeouts: 15s for most APIs (PracticeFusion, Salesforce, RingCentral OAuth/SMS/RingOut, SF task), 10s for email (Postmark + Resend), 5s for Upstash redis (which should be sub-second). Uses native
AbortSignal.timeout()(Node 18+). Sister of inv v299.605 + 2 cron-auth ships earlier today on the canonical-URL/availability hardening arc. tsc clean.
v2.78.902026-05-08ProductionFixed
- π¨ Defensive
canonicalBase()helper across 4 customer-facing crawler surfaces (sitemap.ts + robots.ts + llms.txt + articles.ts). Pre-fix all 4 usedprocess.env.NEXT_PUBLIC_APP_URL || "https://flow.greenwellness.org"which means env var wins β and env var is currently set tohttps://green-wellness-gamma.vercel.app(the Vercel pre-launch URL Doug used during dev). Result: **sitemap advertised 356 URLs** at the Vercel pre-launch URL to Google + AI search engines. Patient-facing /llms.txt cited Vercel-internal URL too. **HIGH-IMPACT SEO RISK**: every day the wrong canonical was in sitemap/robots is a day Google indexed the non-canonical hostname; cleanup post-cutover takes weeks. **Fix**: newcanonicalBase()helper β uses env var ONLY if it doesn't contain.vercel.app; otherwise falls back to canonicalflow.greenwellness.org. Defensive against any future env-var-set-to-Vercel-internal drift class. Applied to:src/app/sitemap.ts+src/app/robots.ts+src/app/llms.txt/route.ts+src/lib/articles.ts. Doug-action still recommended (clear or update NEXT_PUBLIC_APP_URL) but the code now self-corrects. Caught grinding through GW sitemap audit:curl /sitemap.xml | grep -oE "returned 356 URLs all at green-wellness-gamma.vercel.app. tsc clean. Verification next deploy:[^<]+ "curl /sitemap.xml | headshould show flow.greenwellness.org URLs, not vercel.app.
v2.78.702026-05-08ProductionFixed
- π¨ SOFT-404 fix on /providers/[slug] β was returning 200 for unknown slugs (e.g.
/providers/nonexistent); now returns real 404. Sister of scc v8.335 + glw v7.195 cannabis-side fix. Caught grinding through GW dynamic-route audit:/learn/[slug],/conditions/[slug],/locations/[city],/telehealth/[city]all correctly returned 404 for unknown slugs but/providers/[slug]returned 200 (with not-found content). Addedexport const dynamicParams = false;β only slugs fromgenerateStaticParams()(built fromgetAllProviders()DB query at build time) are served. **Trade-off**: a new provider added in /admin/providers won't have a /providers/page until next deploy. Workflow note: provider adds typically happen alongside other site updates so the next deploy catches them. **SEO impact**: Google distinguishes real 404 from soft-404 (200-with-error-content); soft 404s on /providers/[slug] erode the providers index's authority signal. tsc clean. Verification next deploy: curl -fsS -o /dev/null -w "%{http_code}" /providers/nonexistentshould return 404 (was 200).
v2.78.502026-05-08ProductionChanged
- πͺ GW redirect destination upgrade for PTSD:
/marijuana-for-treating-ptsd-and-severe-anxietywas redirecting to/conditions(general index); upgraded to/learn/ptsd-medical-marijuana-washington-state-veterans(the existing 1:1 article slug). Preserves the entire ranking signal for high-value veteran/PTSD search intent (a core GW patient cohort). Sister to v2.78.30 (/how-to-get-...redirect upgrade) β same pattern: when a ranked legacy URL has an exact-match article on the new site, point the redirect at the specific slug rather than the index. Other PTSD-adjacent topics (anxiety, chronic-pain, cancer, etc.) already have specific articles in lib/articles.ts but the legacy URLs that ranked for them weren't on Wayback CDX (only PTSD's was). tsc clean.
v2.78.302026-05-08ProductionFixed
- π¨ SEO RECOVERY round 2 β schedule.greenwellness.org β flow.greenwellness.org sweep across 43 files (46 occurrences) + redirect destination upgrade. Pre-fix 43 src files referenced the old
https://schedule.greenwellness.orgsubdomain (the original two-site plan) instead ofhttps://flow.greenwellness.org(the actual interim live URL per LIVE.md). Sites included: layout.tsx, telehealth pages, learn pages, admin pages, AI-prompt context strings (/api/admin/messages/ai-draft), Twilio webhook reply (/api/webhooks/twilio), articles.ts APP_URL fallback. **Customer-impact**: AI-drafted reply messages directed patients toschedule.greenwellness.org(which doesn't exist as a DNS host) β patients clicking those links would get a DNS-resolution error. **Fix**: bulk replace across all 43 files. Historical changelog entry at line 2934 LEFT alone (historical accuracy preserved). **Plus redirect destination upgrade**:/how-to-get-a-medical-marijuana-card-in-washingtonwas redirecting to/conditions(general index) but matches the existing/learn/how-to-get-medical-marijuana-card-washington-statearticle 1:1 β pivoted to specific article slug to preserve the entire ranking signal (this is the highest-intent customer-discovery search for GW). Sister to scc v8.265 (3 high-traffic legacy URLs upgraded to specific destinations). tsc clean.
v2.77.952026-05-08ProductionChanged
- π‘οΈ Empty-string-defense gate extended to catch variable-fallback class (mirror of inv v295.205). Pre-extension gate caught URL/numeric/email/plain-string
??fallbacks but missed?? UPPER_SNAKE_CASE_CONSTANTshape (e.g.?? EMAIL,?? DEFAULT_FROM). Discovered via v2.77.40 lib/email.ts sweep. New CONST_FALLBACK regex matchesprocess.env.X ?? UPPER_SNAKE_CASE(3+ chars, excludes process.env chains, 1-2-letter false-positives). Comment-line skip + lib/changelog.ts skip added.
Fixed
- π‘οΈ Empty-string-defense β 4 admin-notify sites swept with
process.env.ADMIN_NOTIFY_EMAIL ?? EMAILshape:??β||. Sister to v2.77.40. Pre-fix empty-string ADMIN_NOTIFY_EMAIL env would feed empty admin recipient (silent admin-notify drop on critical events). Sites: api/appointments/route.ts:314 (confirm-email), api/appointments/reschedule/route.ts:113, api/webhooks/stripe/route.ts:28 + 67 (payment_failed + browser-crash recovery alert). Gate scan: 430 files / zero offenders post-sweep. tsc clean.
v2.77.802026-05-08ProductionFixed
- π¨ Canonical URL correction:
flow.greenwellness.orgis the live app subdomain (per LIVE.mdApp URL (live)field), NOTgreenwellness.org(apex still on old WordPress + Sucuri WAF until DNS cutover). v2.77.60 mistakenly pivoted /llms.txt + /robots.ts + /sitemap.ts fallback to the apex per DNS_CUTOVER.md (which is forward-looking β describes the future cutover plan, not current state). The actual interim canonical isflow.greenwellness.org(A flow β 76.76.21.21at GoDaddy, SSL auto-issued by Vercel, verified live viacurl https://flow.greenwellness.org/api/healthreturning 200 with v2.77.30+). Fixed: 3 fallback URLs in /llms.txt + /robots.ts + /sitemap.ts pivotedhttps://greenwellness.orgβhttps://flow.greenwellness.org. Critical because: AI search engines reading the llms.txt fallback (when NEXT_PUBLIC_APP_URL env var is unset) would have learned the WP-WordPress-still-live URL as canonical for the new Next.js app β broken for any AI-cited link before DNS cutover. **Doug-action followup**: when DNS cutover happens (greenwellness.orgapex β Next.js), update env var + this fallback to apex. **Plus**: brand sweep also extended to 12 markdown docs (AGENTS, CHANGELOG, LAUNCH, ROADMAP, etc.) β 22 occurrences ofGreenWellnessβGreen Wellnessconsistent with v2.77.60 src/ sweep. Sister fix on inv portfolio-health-check comment which already correctly citesflow.greenwellness.orgas canonical (no change needed there). tsc clean.
v2.77.602026-05-08ProductionChanged
- π¨ Brand-name canonicalization sweep (Doug 2026-05-08): “it's just **Green Wellness** not GreenWellness medical”. Pre-fix: 282 occurrences across 85 files used “GreenWellness” (no space) or “GreenWellness Medical” (with suffix) in customer-facing copy β title metadata, OG site name, JSON-LD schema name, og alt text, body copy, breadcrumbs, /llms.txt + /llms-full.txt AI-citation surfaces, email templates, SMS body templates, all 4 telehealth city pages, all condition pages, all provider pages, /press, /about, /faq, /terms, /privacy, /changelog, etc. **Critical impact**: AI search engines (ChatGPT/Claude/Perplexity/Gemini) read /llms.txt as authoritative brand context β pre-fix when a user asked an AI “what is GreenWellness?” the model would learn the wrong canonical brand name. Same drift class as today's cannabis-site brand-voice fixes (glw v6.825 family-run + scc v8.205 family-style ownership). Plus canonical URL fix in same sweep: /llms.txt + /robots.ts + /sitemap.ts fallback pivoted from
https://flow.greenwellness.org(the old subdomain plan) tohttps://greenwellness.org(the apex per DNS_CUTOVER.md β Doug pivoted from two-site to one-site at apex). **Preserved exceptions**: GitHub URLdougsureel-tech/GreenWellness(case-sensitive repo path), iCal PRODID//GreenWellness//Scheduler//EN(stable identifier some calendar clients cache), Salesforce custom field API names pivoted toGW_*prefix (Green Wellness_Conditions__cwould be invalid JS identifier syntax). 282/282 occurrences swept; 0 remainingGreenWellnesstokens in src/. tsc clean. **Cumulative pre-cutover GW work today**: v2.77.10 (40+ legacy URL redirects) + v2.77.30 (redirect destinations 404 fix) + v2.77.60 (this β brand-name canonicalization) β full pre-DNS-cutover surface audit.
v2.77.402026-05-08ProductionFixed
- π‘οΈ Empty-string-defense β lib/email.ts from() variable-fallback
process.env.EMAIL_FROM ?? DEFAULT_FROMβ||. Missed by v2.76.60 sweep because the gate's regex matches?? "β not" ??. Same v226.805 footgun: empty-string env passes through??, sendEmail() ships with empty From header β Postmark/Resend may reject or send anonymously. Sibling agent's GreenWellness β Green Wellness brand-name fix bundled in same diff. tsc clean.
v2.77.302026-05-08ProductionFixed
- π¨ GW redirect destinations 404 audit β 2 destinations didn't exist on the new site, breaking the v2.77.10 patient-flow redirects. Pre-fix: (a) **/intake** (no token) returned 404 β only
/intake/[token]/page.tsxexists (token-bound from email). 6 redirects pointed AT bare/intake:/intake-form,/renewal-patients,/new-patient,/new-patient/intake,/new-patient/intake2,/new-patient/renewal-patients. Customers following any of these legacy links would 404. (b) **/careers** returned 404 β no GW careers page exists. 3 redirects targeted it:/careers2,/about-us/careers,/medical-professonals/carrers. **Fix**: pivoted all 6/intakedestinations to/?book=1(the booking-flow URL, same target as/book-now+/get-startedredirects); pivoted 3/careersdestinations to/about(which carries team info). Verified all 13 unique destinations now return 200 viacurl -fsS -o /dev/null -w "%{http_code}": /, /?book=1, /about, /conditions, /faq, /learn, /locations, /privacy, /providers, /sitemap.xml, /telehealth, /telehealth/olympia-telehealth, /terms. Same drift class as glw v6.905 (/contact 308'd to /visit when /contact had a real page) β destination-existence verification should be part of any redirect-block audit. Caught grinding past first sweep perfeedback_keep_grinding_past_diminishing_returnsβ first sweep landed redirects pointing at non-existent destinations; verification round caught the gap.
v2.77.102026-05-08ProductionAdded
- π¨ SEO RECOVERY (pre-DNS-cutover): comprehensive Wayback CDX audit + ~40 additional legacy URL redirects in
next.config.tsto bring GW pre-cutover URL coverage to same-or-better than the cannabis-site sweeps (glw v7.045 + scc v8.225). Doug 2026-05-08 ask: "build them out and make sure they are the same or better. do green wellness too. let's launch and figure out the bugs as we go IF we are all HIPAA compliant." Wayback CDX confirmed 50+ unique paths crawled in 2024-2026 ongreenwellness.orgβ original redirect block covered ~25 known WP URLs but missed: (a)/about-ussubtree (5 variants); (b)/contact-us,/contact-us2legacy slugs; (c)/home,/home-2WP defaults; (d)/medical-professonalstypo (and the/carrerstypo under it); (e) city landing alternates:/lynnwood-medical-marijuana-card2,/spokane-valley-medical-marijuana-card-2,/spokane-marijuana-medical-cannabis,/vancouver-medical-marijuana-card,/vancounver-medical-marijuana-card(typo),/spokane-medical-marijuana,/olympia-medical-marijuana-doctor; (f)/new-patient/*subtree (4 variants); (g) HIGH-INTENT blog post/how-to-get-a-medical-marijuana-card-in-washington(exact customer-discovery search); (h) 12 other legacy blog posts; (i)/shop,/wishlist(old WC e-commerce stubs); (j)/uncategorized(WP default category); (k) 4 sitemap variants (Yoast/Rank Math). Each β semantic equivalent on the new site (/about,/careers,/conditions,/learn,/intake,/providers,/telehealth,/sitemap.xml,/). **Why before cutover**: cannabis sites leaked SEO for ~30 days post-Next-cutover before the redirect gap was caught β gating GW so it doesn't repeat the same drift class. Sister to glw v7.045 + scc v8.225. Cumulative pre-cutover URL preservation arc closes the gap pre-launch.
v2.76.902026-05-08ProductionChanged
- π‘οΈ Build-gate
scripts/check-env-fallback-pattern.mjsextended to catch plain-string??fallback class (cross-repo port of inv v294.605). Pre-extension regex caught URL/numeric/email patterns; plain-string fallbacks like?? "development"/?? "NOT SET β uses fallback ..."/?? "sk_test_placeholder..."were silent footguns when env vars were set to empty string. AddedPLAIN_FALLBACK = /process\.env\.[A-Z0-9_]+\s*\?\?\s*"[A-Za-z(][^"]+"/regex matching plain-string fallbacks of 2+ chars starting with alpha or paren (excludes""intentional defaults + non-alpha-leading like"β"placeholders). Gate scan: 431 files, zero offenders post-sweep.
Fixed
- π‘οΈ Empty-string-defense β 4 sites swept (
??β||) before extending gate (would have failed strict mode post-extension): src/app/admin/launch/page.tsx:191 (VERCEL_ENV diagnostic β empty would render 'Production env: ' instead of 'development') Β· src/app/admin/launch/page.tsx:548 (NEXT_PUBLIC_APP_URL canonical-URL panel β empty would silently keep raw '' instead of triggering the 'NOT SET' fallback message) Β· src/app/api/health/route.ts:49 (env classifier in /api/health JSON response) Β· src/lib/stripe.ts:5 (STRIPE_SECRET_KEY ?? sk_test_placeholder β empty would feed empty string to Stripe SDK ctor, masking missing env in dev). Cumulative empty-string-defense arc: ~146 sites / 4 repos. tsc clean.
v2.76.802026-05-08ProductionAdded
- π lib/tz.ts NEW export
CLINIC_TZβ was a private const, now exported for cross-file TZ binding (matches inv STORE_TZ pattern). Enables ad-hoctoLocaleDateString/toLocaleStringcalls across components to bind to the canonical clinic TZ instead of leaving display in server (UTC) TZ.
Fixed
- π TZ-binding sweep β 6 sites bound on Date.toLocaleString/Date.toLocaleDateString options (cross-repo port of inv v293.605 round 6). Pre-fix these
new Date(iso).toLocaleDateString("en-US", { ...options-without-timeZone })calls render in server's TZ (UTC on Vercel) instead of America/Los_Angeles. Customer-visible drift: a 9pm doc upload would render as next-day on /admin/patients DocumentsList. Sites swept: src/app/admin/users/page.tsx (Γ2 β last-active fmt + history events), src/app/admin/patients/[id]/_components/DocumentsList.tsx (uploadedAt), src/app/admin/dispensaries/page.tsx (cert createdAt), src/app/admin/compliance/page.tsx (HIPAA-doc 'last reviewed' today stamp), src/app/dispensary/dashboard/page.tsx (cert formatDate only). **NOT swept** (intentionally): src/app/dispensary/dashboard/page.tsx formatDob β date-of-birth is a stable calendar date, not a timestamp; binding TZ could shift DOB by 1 day across midnight boundaries (well-known footgun). Cumulative cross-repo TZ-binding arc: 44 (inv) + 6 (GW) = 50 sites bound. tsc clean.
v2.76.702026-05-08ProductionFixed
- π‘οΈ buildMedicalBusinessLd JSON-LD reviewRating bug β empty-string env-var fed Number("")=0, rendering schema.org/AggregateRating with
ratingValue: 0instead of 4.9 default. Pre-fixlib/seo.ts:80-81usedNumber(process.env.NEXT_PUBLIC_REVIEW_RATING ?? 4.9)chain. The??falls through only on null/undefined β empty string passes through toNumber("")which returns 0, not the 4.9 fallback. Same footgun for NEXT_PUBLIC_REVIEW_COUNT but harm-zero (0 default matches Number("") = 0). Real SEO impact for REVIEW_RATING: Google reads aggregateRating as 0/5 stars on the homepage JSON-LD, hurting medical-business rich-result eligibility. Fix uses the truthy-check pattern already used in src/components/sections/Hero.tsx + Reviews.tsx:process.env.X ? Number(process.env.X) : DEFAULT. Empty-string env now falls through to default cleanly. Sister to v2.76.60 ?? β || sweep β closes the ONLY remaining numeric-env-var defensive gap in GW (matches inv v249.405 STORE_SQFT defense). tsc clean.
v2.76.602026-05-08ProductionFixed
- π‘οΈ Empty-string-defense sweep β email/SMS env-var fallbacks 9 sites:
??β||(cross-repo port of inv v293.405 payroll-tax-form sweep + inv v281.205 email-fallback sweep). Pre-fix GW hadprocess.env.ADMIN_NOTIFY_EMAIL ?? 'admin@greenwellness.org'style fallbacks across 4 admin/cron email-send paths + EMAIL_FROM + EMAIL_REPLY_TO + POSTMARK_STREAM + 2Γ RC_FROM_NUMBER||TWILIO_PHONE_NUMBER chains. The??only falls through on null/undefined β if Vercel env var is set to empty string (v226.805 footgun pattern), weekly-digest emails go to blank recipient (silently dropped) / unsubscribe mailto links break / Postmark message-stream invalid / SMS sends from blank fromNumber. Real risk for HIPAA-compliant clinic where missed notifications hurt patient care. **Files (7 files, 9 logical sites):** api/admin/messages/send/route.ts (Γ2 β EMAIL_FROM + RC_FROM_NUMBER chain), api/admin/weekly-digest/send/route.ts (Γ2 β recipient + note), api/admin/eod-email/send/route.ts (Γ2 β recipient + note), api/cron/daily-briefing/route.ts (ADMIN_NOTIFY_EMAIL), api/cron/weekly-digest/route.ts (ADMIN_NOTIFY_EMAIL), lib/email.ts (Γ2 β EMAIL_REPLY_TO mailto + POSTMARK_STREAM), api/admin/outreach/route.ts (RC_FROM_NUMBER chain). Webhook payload??patterns intentionally NOT swept β those have valid empty-string semantics (RC reports caller-ID-blocked as empty). All env-var fallback values are strings; no numeric/boolean falsy cases. Cumulative empty-string-defense arc: ~112 sites across 4 repos. tsc clean.
v2.76.502026-05-08ProductionAdded
- π’ **
lib/time-constants.tsNEW SSoT + 8 callsites swept** β cross-repo port of invsrc/lib/time-constants.ts(post-v286.805/v287.405/v288.605/v289.205/v289.805 5-round sweep β 34 sites bound) + glwlib/time-constants.tsv6.705 (11 sites bound) + scclib/time-constants.tsv7.945+v7.985 (11 sites bound). Pre-fix GW had 8 inlined60_000magic-number literals scattered across calendar tick + today's-board minutes-late + idle-timeout countdown + visit-join card + previsit minsUntil + dispensary-dashboard tick + ringcentral OAuth-token expiry buffer. **Sites swept (8 across 7 files):**app/admin/calendar/page.tsx:58setNowMinutes tick Β·app/admin/today/page.tsx:277minutesLate calc Β·app/admin/_components/InactivityGuard.tsx:6+7+8+37IDLE_TIMEOUT_MS + WARN_BEFORE_MS + CHECK_INTERVAL + remaining-mins calc (4 changes in this file β three nested-multiply consts + one inline calc) Β·app/visit/[token]/_components/JoinVisitCard.tsx:33+34minsToStart + minsAfterEnd Β·app/api/previsit/[token]/route.ts:93minsUntil Β·app/dispensary/dashboard/page.tsx:58setInterval tick Β·lib/ringcentral.ts:42token-cache expiry buffer. Same SSoT shape as inv/glw/scc (SECOND_MS / MINUTE_MS / HOUR_MS / DAY_MS / WEEK_MS) β composed bottom-up so multipliers chain. Zero underscore-literal magic numbers remain in GW src/. **Cumulative cross-repo sweep total: 34 (inv) + 11 (glw) + 11 (scc) + 8 (GW) = 64 sites bound across all 4 repos.** tsc clean.
v2.76.402026-05-08ProductionFixed
- π‘οΈ **Console.error PHI redaction β 3 patient-id sites bound to
patientIdPrefix.slice(0, 8).** Sister of inv v287.805 vendor-access PII fix. Per HIPAA Safe Harbor Β§164.514(b)(2)(i)(R), full UUIDs count as 'unique identifying codes' β must be redacted in any logging surface that's broader-read than the audit-log table itself (Vercel logs are NOT BAA-covered; redaction is required even for what looks like 'just an internal ID'). **Sites swept (3):**lib/workflow.ts:127workflow-log-failed catch (logs every workflow event log-write failure across the whole app β touched by every cron + every patient-facing email path) Β·lib/patient-message-backfill.ts:59backfill failure log (call-recording β patient match) Β·app/api/admin/patients/[id]/send-renewal/route.ts:58admin-triggered WIN_BACK send. All 3 now usepatientId.slice(0, 8)aspatientIdPrefix. Each adds an inline comment citing the HIPAA Safe Harbor section so future-agent doesn't revert. The remainingpatient=${id}patterns inaudit()calls (cancel/route.ts L66, confirm/route.ts L37, etc.) intentionally untouched β those write to the DB audit-log table which IS PHI-scoped (HIPAA requires audit logs to retain identifying info). tsc clean.
v2.76.302026-05-08ProductionFixed
- π‘οΈ **Empty-string-defense round 3 (scripts/) β 3 sites swept in 2 .mjs maintenance scripts.** Closes the scripts/-dir gap on the v2.76.20 sweep (which port-extended the gate to scan src/ but left
.mjsscripts untouched). **Sites swept**: scripts/sf-inventory.mjs L55-56 (SF_API_VERSION ?? "v59.0"+Number(SF_SAMPLE_SIZE ?? "5")β Salesforce-bulk-export utility) Β· scripts/rc-register-webhooks.mjs L42-43 (RC_SERVER_URL ?? "https://platform.ringcentral.com"+APP_BASE_URL ?? "https://green-wellness-gamma.vercel.app"β RingCentral webhook registration). All 5 fallbacks now use||so an operator-cleared-to-""env-var falls through to the in-code default instead of producing empty-string SERVER/APP_BASE/SAMPLE_SIZE. Defense-only β these are dev-side scripts, not Vercel runtime, so the live-incident risk is lower than the page-tsx sweep but the convention stays consistent across the codebase. Sister of inv v282.605 (which extended inv gate to scan scripts/ + swept seed-product-images.ts). GW gate scope NOT extended to .mjs because the gate file itself is .mjs and would self-trigger from its own doc-comment example pattern (would need gate-file-skip logic β deferred). tsc clean (.mjs files don't go through tsc anyway).
v2.76.202026-05-08ProductionFixed
- π‘οΈ **Empty-string-defense round 2 (page-tsx) β 24 sites + build-gate ported from inv. Closes the gap left by v2.76.9.** The v2.76.9 sweep covered 31 api-route files but missed 24 page-level sites (every
src/app/**/page.tsxthat locally declaredconst APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "https://flow.greenwellness.org"). Same bug class: empty-string env-var β href becomes""instead of falling through. **21 files swept**: app/layout.tsx, /about, /conditions, /confirm/[token], /learn (Γ 2), /leave-a-review (4 review-link href fallbacks for Google/Yelp/Healthgrades/Leafly), /locations (Γ 3), /patient/portal, /pricing, /providers, /telehealth (Γ 3), /admin/appointments/[id] (Γ 2: const + inline JSX), /admin/content, /admin/patients/[id] CopyReferralLinkButton, /api/cron/review-request (template-literal fallback chain), components/scheduling/StepConfirmation. **Build-gate ported from invscripts/check-env-fallback-pattern.mjs** (catches?? "AND" ?? \...@...\`/?? \mailto:...\template-literal fallbacks). Run manually vianode scripts/check-env-fallback-pattern.mjs` β 430 files scanned post-sweep, zero offenders. Sister of inv v281.205 + glw v6.605 + scc v7.805 cross-repo defense wave. tsc clean.
v2.76.102026-05-08ProductionFixed
- π‘οΈ **Auto-ack template β CRLF/control-char strip on inboundSubject (header-injection defense).** Final layer on the v2.76.0-9 auto-ack hardening stack. The subject was passed through untouched (just trimmed + length-capped); a malicious or malformed inbound email subject containing CR/LF could theoretically inject extra email headers when the auto-ack is composed. Real email vendors like Postmark reject CR/LF in headers at the SDK layer, but template-level defense is cheap + future-proof against vendor swap (e.g. if we move to SES). Now strips
[\r\n\x00-\x1f]from the inbound subject before threading. Defense-in-depth without changing the happy-path behavior. Sister to v2.76.0-9 auto-ack hardening series.
v2.76.92026-05-08ProductionFixed
- π‘οΈ **GW-side
??β||empty-string-defense sweep β 31 files.** Sister to inv v248.805 + v249.405 batches. Same bug class:process.env.NEXT_PUBLIC_APP_URL ?? "https://flow.greenwellness.org"falls through ONLY on null/undefined; an empty-string env-var (NEXT_PUBLIC_APP_URL="") is used directly, breaking every patient-facing magic-link, portal-link, ICS calendar URL, and email template across the app. 31 files swept (10 admin appointment routes, 5 patient/provider auth routes, 7 cron handlers, 4 lib helpers, 2 lib content files, llms.txt, recording proxy via RC_SERVER_URL, ringcentral.ts SERVER constant). Allprocess.env.X ?? "https..."and the one?? "http://localhost"migrated to||. tsc clean. Closes the empty-string-fallback bug class across BOTH projects in one day's session.
v2.76.82026-05-08ProductionFixed
- π§ **email-templates.ts BRAND const β canonical SoT (lib/seo.ts SITE_LEGAL_NAME).** v2.76.0 hardcoded
BRAND = 'Green Wellness'in the email-templates file. lib/seo.ts already exportsSITE_LEGAL_NAME = 'Green Wellness'as the canonical brand source for the entire site (used in title tags, OG meta, breadcrumbs). Two sources = drift risk. Migrated email-templates.ts to import from lib/seo.ts so a future brand change propagates from one place. HOURS + REPLY_WINDOW remain inlined per the standing rule 'don't abstract until a second caller needs it.' Sister to v2.76.0 auto-ack template ship.
v2.76.72026-05-08ProductionAdded
- π¦ **/admin/launch β surface EMAIL_AUTO_ACK_ENABLED feature flag.** Sister to v2.76.6 HIPAA_COMPLIANT row. v2.76.4 added the email auto-ack feature flag but didn't surface it in the launch-readiness Feature-flags section, sitting next to ADMIN_TOTP_ENABLED + AI_DRAFTS_ENABLED. Now Doug sees ON/OFF state at-a-glance with copy that documents the multi-layer gating ('also gated on Postmark/SES BAA + 4hr per-sender rate limit'). Closes the feature-flag-discoverability arc β every
*_ENABLEDflag in the GW codebase is now surfaced on /admin/launch (3 of 3: TOTP + AI Drafts + Auto-ack).
v2.76.62026-05-08ProductionAdded
- π¦ **/admin/launch β HIPAA_COMPLIANT attestation flag now surfaced as its own readiness row.** v2.76.5 added the env-var-driven flip from 'HIPAA-aware' β 'HIPAA-compliant' marketing label. Without surfacing it on /admin/launch, Doug would have to remember the flag exists to flip it. Now: when he opens the launch-readiness cockpit, there's an explicit row showing whether the attestation is ON or OFF β with a copy that reminds him 'verify all 4 BAAs signed before keeping this on' (Anthropic + Postmark/SES + AT&T voice + Twilio SMS). Severity:'caveat' when off (intentional default), 'ready' when on. Closes the v2.76.5 launch-day-flip pattern with discoverable surfacing. Sister to v2.75.29 voice/SMS row split.
v2.76.52026-05-08ProductionAdded
- π¦ **TrustBar HIPAA-compliant attestation flag (
HIPAA_COMPLIANT=true).** When all four PHI-touching vendor BAAs are signed (Anthropic + Postmark/SES + AT&T Office@Hand voice + Twilio SMS), Doug flips the env var and the trust badge under the hero changes from 'HIPAA-aware design' to 'HIPAA-compliant design.' Comment explicitly notes the env-var is Doug's ATTESTATION β flipping it without all four BAAs signed is a marketing-truthfulness liability. Default OFF preserves the conservative 'HIPAA-aware' framing until BAAs land. One env-var flip = marketing language upgrade, no code change at launch. Sister to v2.76.0-4 auto-ack hardening stack (operability flag pattern). tsc clean.
v2.76.42026-05-08ProductionAdded
- π¦ **Auto-ack feature flag β
EMAIL_AUTO_ACK_ENABLED=true(default OFF).** Final defense layer on the v2.76.0-3 auto-ack stack: Doug can now roll back the entire auto-ack feature instantly by unsetting the env var + redeploying (or just unsetting + waiting for next cron tick β the gate evaluates per-request). Use case: launch ack alongside Postmark BAA, observe a few real inbounds for tone/timing, then leave on. If anything misbehaves post-launch (loop with a vendor we hadn't tested, customer complaint about the wording, unexpected delivery delay), env-var unset = instant rollback with no code change. The flag is the OUTERMOST gate (cheapest to evaluate) so the early-exit path is dead-fast on every inbound webhook hit when the feature is off. Closes the auto-ack hardening arc with a 4-layer defense: feature-flag (operability) + BAA-gate (regulatory) + per-sender rate limit (loop class) + try/catch wrap (send failure swallow). tsc clean.
v2.76.32026-05-08ProductionFixed
- π‘οΈ **Auto-ack HIPAA gate β BAA-covered providers only (Postmark + SES, NOT Resend).** v2.76.0 fired the auto-ack on
activeProvider() !== 'none'β too loose. While the auto-ack body content has no PHI, the metadata disclosure (sender=greenwellness.org β recipient=patient_email) is itself protected: it discloses that the recipient is communicating with a medical clinic, which is a HIPAA-covered relationship. Resend has no BAA, so the auto-ack can't route through it. Tightened gate toprovider === 'postmark' || provider === 'ses'β auto-ack no-ops cleanly on Resend until Postmark or SES BAA is signed. Inbound is still stored + processed; staff replies via the in-app composer go out manually under whatever BAA posture is active when Send is hit. Defense-in-depth on top of v2.76.0 + v2.76.2; closes the auto-ack HIPAA gate at the metadata-disclosure level. tsc clean.
v2.76.22026-05-08ProductionAdded
- π‘οΈ **Auto-ack per-sender rate limit (loop defense).** v2.76.0 shipped the email auto-acknowledge with a comment claiming 'webhook already drops auto-submitted senders, so this won't loop' β true for STANDARDS-COMPLIANT auto-responders. But not every auto-responder tags
Auto-Submittedcorrectly (some medical-practice mailers, older enterprise systems, human-driven rapid forwarding). AddedshouldSendAutoAck(fromEmail)per-sender rate limit: 1 ack per email per 4 hours. Storage = in-memory Map shared across requests within a Vercel Fluid Compute instance (cheap, zero DB reads, worst-case = one extra ack per cold-start). Opportunistic cleanup purges expired entries every ~100 inbound emails so the Map doesn't unbounded-grow on a long-lived instance. Defense-in-depth on top of the v2.76.0 anti-loop posture. tsc clean.
v2.76.12026-05-08ProductionFixed
- π **components/sections/TrustBar.tsx β calls-only BAA list comment.** Sister to v2.75.29-35 calls-only sweep (long tail). The comment block above the SIGNALS array said the trust badge would flip to 'HIPAA-compliant' 'once BAAs are signed with Anthropic, Postmark/SES, and RingCentral' β RingCentral framing is stale post-decision. Updated to call out Anthropic + Postmark/SES + AT&T Office@Hand voice + Twilio SMS as the four BAAs that gate the language flip. Comment-only change, no rendered-output change. Closes the calls-only doc-correctness arc on a sneaky tail file (the marketing-page comment that documents the language-flip trigger).
v2.76.02026-05-08ProductionAdded
- β¨ **Email auto-acknowledge β pattern #3 from PLAN_EMAIL_AI.md (the no-AI piece).** When a patient emails in (
/api/webhooks/postmark/inbound-email), the system now fires a fire-and-forget auto-acknowledgment back: 'Hi [name], thanks for reaching out β we got your message. A team member will reply within 4 business hours.' Newlib/email-templates.tsholds the static HTML template (HIPAA-safe by design β no PHI in the body, only first-name personalization if patient is matched). Inbound webhook gates send onactiveProvider() !== 'none'so it no-ops cleanly when no email vendor is configured. Send failures are caught + console-logged (NEVER throw) so the webhook always returns 200 β Postmark retries non-2xx, and double-acks would be worse than no ack. Anti-loop posture: the inbound webhook already dropsAuto-Submittedsenders + bounces (mailer-daemon@, postmaster@), so this template can fire on every accepted inbound. The other 2 PLAN_EMAIL_AI.md patterns (triage classifier + auto-draft reply) are still gated on Anthropic Zero-Retention BAA + email vendor BAA. **First piece of the email-AI roadmap that ships under the existing Resend infra (no PHI = no BAA gate).** Sister to PLAN_EMAIL_AI.md + PLAN_EMAIL_REPORTS.md (saved this session). tsc clean.
v2.75.352026-05-08ProductionFixed
- π **ROADMAP.md Β§12f credentials list β calls-only RC entry.** Sister to v2.75.29-34 calls-only sweep. The Β§12f vendor checklist for blocking automation said RingCentral covers 'click-to-call, inbound call log webhook, optional SMS, voicemail drop' β the 'optional SMS' framing is misleading post-decision. Updated to lead with CALLS ONLY + 'NO SMS via RC' + cross-references to PLAN_EMAIL_AI.md and the calls-only memory pin. Sister cross-project:
/CODE/Green Life/OUTSTANDING_WORK.mdrow 10 also updated to reflect the split (separate Twilio HIPAA BAA + A2P 10DLC item). The calls-only framing is now consistent across BOTH the GW project + the cross-project work tracker on the cannabis side.
v2.75.342026-05-08ProductionFixed
- π **3 launch docs β calls-only split: LAUNCH.md + LIVE.md + GO_LIVE_CHECKLIST.md.** Sister to v2.75.29-33 calls-only sweep. **LAUNCH.md** Β§B.4 (vendor BAAs) bundled SMS + voice into a single 'RingCentral / AT&T Office@Hand BAA + A2P 10DLC' item that included 'Pick which of the 8 numbers is the SMS sender' β wrong post-decision. Split into 4 (RC voice, ~1 week, no 10DLC) + 4b (Twilio HIPAA BAA + A2P 10DLC, ~3-4 weeks). Β§C.7 Twilio item recharacterized from 'interim SMS until RingCentral is live' to 'production SMS path.' Β§Open Questions Β§4 'SMS strategy β Twilio bridge or wait for RC' marked RESOLVED 2026-05-08. **LIVE.md** Β§Outstanding-vendor-BAAs split into voice + SMS lines. **GO_LIVE_CHECKLIST.md** Day-1 vendor row split. All 3 docs now match the split TODO.md from v2.75.33. Closes the calls-only doc-correctness arc across the entire GW project. Sister to memory pin
project_greenwellness_office_at_hand.md.
v2.75.332026-05-08ProductionFixed
- π **TODO.md β split RC / Twilio Doug-actions per calls-only decision.** Sister to v2.75.29-32 calls-only sweep. Three TODO entries (lines 18, 39, 79) still framed RingCentral as the SMS path + bundled SMS+voice into one RC item. Per Doug's 2026-05-08 calls-only Office@Hand decision, these are SEPARATE Doug-actions: (1) AT&T Office@Hand voice BAA + RC dev portal app (calls scopes only, NO SMS scopes, DO NOT set
RC_FROM_NUMBER); (2) Twilio HIPAA BAA + A2P 10DLC (production SMS path). Updated all 3 TODO entries with explicit voice-vs-SMS split + flagged the in-progress 2026-05-08 RC dev portal signup state (Client IDaW89ksJVjyEfrnFJEzoGma, sandbox JWT received, prod JWT pending). Sister to memory pinproject_greenwellness_office_at_hand.md. Closes the calls-only ripple-cleanup arc at the project's authoritative TODO list.
v2.75.322026-05-08ProductionFixed
- π§ **lib/workflow.ts SMS-routing comment β final source-of-truth alignment.** Sister to v2.75.29-31 calls-only Office@Hand sweep. The header comment on the
sendSms()SMS-vendor routing said 'prefers RingCentral when RC_* env vars are set, falls back to Twilio' β under Doug's 2026-05-08 calls-only decision, that framing is misleading: it implies a developer should setRC_FROM_NUMBERto route SMS via RC, when actually that would BREAK the calls-only setup (no A2P 10DLC registration on Doug's RC account β patient SMS would silently fail). Comment rewritten to lead with 'Twilio handles SMS in production' + flagRC_FROM_NUMBERas a kill-switch + 'DO NOT set without confirming with Doug.' Code unchanged βrcConfigured()already returns false withoutRC_FROM_NUMBER, so the routing already correctly falls through to Twilio. Closes the SMS-framing arc at the source-of-truth file. Sister to memory pinproject_greenwellness_office_at_hand.md. tsc clean.
v2.75.312026-05-08ProductionFixed
- π§ **4-file SMS-vendor framing sweep** β sister batch to v2.75.29 + v2.75.30. Doug 2026-05-08 calls-only Office@Hand decision rippled into stale framing across 4 staff-facing surfaces: (1)
/admin/messagesempty-state copy said 'Patient SMS replies appear here when RingCentral is wired up' β now correctly says Twilio + BAA framing, voice surface still says Office@Hand/RC. (2)/admin/launchSmokeTestPanel SMS-test footnote said 'RingCentral preferred, Twilio fallback' β now reflects calls-only setup. (3)/admin/trainingRun-the-five-smoke-tests body said 'real text via RingCentral or Twilio' β now flags BAA expectation + Doug-decision context. (4)/api/admin/smoke-test/smsroute header comment had the same stale framing. Closes the SMS-framing arc on the calls-only Office@Hand decision; staff training docs + admin UI now consistent. tsc clean.
v2.75.302026-05-08ProductionFixed
- π§ **/api/admin/smoke-test/sms β error message correction.** Sister to v2.75.29 launch-page split. Error message when no SMS provider is configured had stale framing β described Twilio as 'no BAA β dev only' which is wrong: Twilio offers HIPAA BAA via their HIPAA program, AND under Doug's 2026-05-08 calls-only Office@Hand decision, Twilio with BAA IS the recommended SMS production path. Updated error message to lead with Twilio + HIPAA program framing + de-prioritize the SMS-via-RC path. Sister to memory pin
project_greenwellness_office_at_hand.md.
v2.75.292026-05-08ProductionFixed
- π§ **/admin/launch integration check β RC voice vs SMS split.** Per Doug 2026-05-08 calls-only Office@Hand decision (memory pin
project_greenwellness_office_at_hand.md), AT&T Office@Hand handles voice ONLY. SMS stays on Twilio. The launch-readiness page was checkingRC_CLIENT_ID && RC_JWT_TOKEN && RC_FROM_NUMBERas a single 'ringcentral' bool + then claiming RC handles SMS when set. Wrong: (a) RC voice doesn't need RC_FROM_NUMBER (the in-app softphone widget uses NEXT_PUBLIC_RC_CLIENT_ID + iframe auth, not REST ringOut); (b) Doug intentionally NOT setting RC_FROM_NUMBER β that's the kill-switch keeping SMS on Twilio (workflow.ts falls through automatically). Fix: split intoringcentralVoice(CLIENT_ID + JWT_TOKEN) +ringcentralSms(adds FROM_NUMBER). Added a new 'Voice integration' row showing voice creds + softphone widget client ID separately. SMS provider row now correctly shows Twilio as active (with BAA framing). Sister to /CODE/Green Wellness/PLAN_EMAIL_AI.md + memory pin. tsc clean.
v2.75.282026-05-08ProductionFixed
- π‘οΈ **Last
!==against env-var: admin-login first-run seed.** Final pass of cross-handler scan flaggedpassword !== process.env.ADMIN_PASSWORDinensureAdminExists(). Same class as v2.75.13/14/26/27 timing leak. The first-run seed only fires while adminUser table is empty (production setup window) so the leak is time-bounded β but a setup-time leak lets an attacker recover ADMIN_PASSWORD before the first real admin login and become the system's first ADMIN account. Fixed viatimingSafeEqualStr. With this commit zero!==-against-env-var-secret patterns remain in the codebase. tsc clean.
v2.75.272026-05-08ProductionFixed
- π‘οΈ **4 integrations routes β
!== process.env.CRON_SECRETtiming leak.** Continuation of v2.75.13 / v2.75.14 / v2.75.26 cross-handler scan:/api/integrations/email,/api/integrations/sms,/api/integrations/salesforce,/api/integrations/practicefusionall usedreq.headers.get("x-internal-secret") !== process.env.CRON_SECRETfor auth. Two issues: (1)!==short-circuits on length mismatch β timing leak lets an attacker probe the secret length over WAN. (2) When CRON_SECRET is unset, comparison becomes; header values are always string-or-null (never the JS!== undefined undefined), so this case is fortunately not bypassable like the cron-auth template-literal case was β but the timing leak still applies. Fix: replace withverifyCronAuth(req)from v2.75.14, which already supportsx-internal-secretwith constant-time compare AND fail-closed when env unset. With this commit every CRON_SECRET-using auth surface in the codebase routes through the same constant-time helper. tsc clean.
v2.75.262026-05-08ProductionFixed
- π¨ **Session signature comparison was non-constant-time on ALL 4 user types β timing-leak attack on every authenticated request.** Highest-leverage auth surface in the app. Every admin/patient/provider/dispensary session validator used
if (sig !== expected)for the HMAC signature check. JS string!==short-circuits on first mismatched byte, so a remote attacker timing the response can recover the expected signature byte-by-byte via statistical analysis: send a candidate sig, measure response time, the candidate that takes ~1 CPU cycle longer to reject is the one with the next correct byte. Recover the signature β forge a session β bypass auth entirely. Same vulnerability class as v2.75.13 (SF webhook secret) and v2.75.14 (cron auth). Fix: newsrc/lib/timing-safe.tswithtimingSafeEqualStrβ Edge-compatible (Web Crypto runtime, no Nodecrypto.timingSafeEqualavailable) constant-time string compare via XOR-accumulator over the full length. All 4 session validators now use it for the HMAC sig check. tsc clean.
v2.75.252026-05-08ProductionFixed
- π‘οΈ **Stripe webhook payment_failed leaked patient PII to non-BAA email β HIPAA-direct.** The payment_failed handler interpolated
meta.firstName + meta.lastNameinto the alert subject (Payment failed β John Smith) andmeta.emailinto the body, then sent via Postmark. Two issues: (1) The success-path comment a few lines below explicitly says "Patient PII is no longer stored in Stripe metadata (HIPAA data minimization)" β but the failed-path was still using those fields. Either contradictory or the metadata still has PII; either way the failed path was leaking. (2) Postmark BAA is still pending per LAUNCH.md, so any patient-identifying email through it is a breach. Email subjects in particular land in inbox previews, push notifications, lock-screen banners β leakage surface beyond just the email body. Fix: subject nowPayment failed β $XX.XX (Stripe abc12345)(amount + last-8 of intent id, both non-PHI). Body has Stripe dashboard link only β admin clicks through for patient details, which keeps PHI inside Stripe's BAA-covered surface. tsc clean.
v2.75.242026-05-08ProductionFixed
- π‘οΈ **Password max-length cap added across all 5 password-setting routes** β closes a CPU-DoS vector via huge bcrypt input. Bcrypt's effective input limit is 72 bytes; anything longer is wasted CPU on the cost-12 hash. Pre-fix the routes had
min(8)but no max β an attacker could submit a 4MB password and consume CPU during the bcrypt step until the Vercel function timeout (now 300s default) fires. Multiplied across many concurrent requests, this is a cheap DoS amplifier on the auth surface. Fix: capped at 200 chars acrossadmin/reset-password(Zod.max(200)),provider/reset-password,patient/auth/set-password,patient/auth/change-password,patient/auth/reset-password. v2.75.23 already added the cap toadmin/usersPOST + PATCH. With this commit every password-input surface has the same 8-200 bound. tsc clean.
v2.75.232026-05-08ProductionFixed
- π‘οΈ **
/api/admin/usersPOST + PATCH β password length validation bypassed.** Cross-handler scan of all password-creation/reset surfaces (patient set/change/reset, admin reset, provider reset) showed each requires min 8 characters β but/api/admin/usersPOST + PATCH (the staff-account create/update routes used by ADMIN role) had onlyif (!password)which accepts any truthy string. A malicious or careless admin could create another admin with password"a"via this surface, bypassing the floor enforced everywhere else. Highest-privilege foot-gun: this is the canonical 'compromised admin β backdoor' path (already audited viaCREATE_ADMIN_USER/UPDATE_ADMIN_USERper v2.74.16). Fix: addedpassword.length < 8 || password.length > 200validation matching the other password surfaces. Max 200 caps DoS via huge bcrypt input (bcrypt's effective 72-byte limit means longer is wasted CPU anyway). tsc clean.
v2.75.222026-05-08ProductionFixed
- π‘οΈ **Audit-trail forgery class β
x-admin-idspoofing on webhook + cron + integration routes.** Critical HIPAA-relevant find. proxy.ts had afreshHeaders()function that strips client-supplied identity headers (x-admin-role,x-admin-id,x-admin-name,x-provider-id,x-provider-name,x-dispensary-id,x-dispensary-user,x-patient-id) before they reach handlers β but the matcher only fired for/admin /provider/portal /dispensary /patient/portalpaths./api/webhooks/*,/api/cron/*,/api/integrations/*were OUTSIDE the matcher, so identity headers passed through untouched. Theaudit()helper auto-attributes viaheaders().get("x-admin-id"), so a malicious request to e.g./api/webhooks/postmark/inbound-emailwithx-admin-id:set could pass the webhook's signature check (Postmark Basic-Auth) and have audit() falsely attribute the action to that admin. Forensic-trail forgery for any action the webhook triggers. Same vector for cron + SF/PF integration routes. Fix: extended the proxy.ts matcher to cover/api/webhooks/* /api/cron/* /api/integrations/*and added a clean-headers branch that strips identity headers and lets the request through (no session check β those routes do their own signature/secret validation). audit() now CANNOT auto-attribute to a forged admin from any of these paths. tsc clean.
v2.75.212026-05-08ProductionFixed
- π‘οΈ **HTTP-header injection (CRLF) defang on 3 file-streaming routes β sanitization inconsistency was the real gap.** Cross-handler scan of all
Content-Disposition: filename=...interpolation sites flagged 3 routes with too-loose sanitization: (1)/api/admin/cert/[id]stripped only whitespace (replace(/\s+/g, "_")); (2)/api/admin/messages/.../attachmentsstripped only double-quotes (replace(/"/g, "")); (3)/api/provider/documents/[id]same as #2. All three were vulnerable to CRLF injection via patient-controlled / sender-controlled filenames β a name containing\r\ncould inject arbitrary HTTP headers (Set-Cookie, Location, etc) into the response. Two other routes (/api/admin/documents/[id],/api/patient/cert/[id],/api/dispensary/cert/[token]) already had proper allowlist sanitization ([^a-z-]or[^\w.\-]). Now all 3 vulnerable routes use the same strict allowlist[^\w.\-]/g, "_"β alphanumeric + dot + hyphen + underscore only. Patient/sender-controlled bytes can no longer escape the filename quotes or terminate the header. tsc clean.
v2.75.202026-05-08ProductionFixed
- π‘οΈ **CSV formula injection (CWE-1236) closed across all 6 CSV-export routes β major security gap.** Scan flagged that 4 of 6 admin CSV-export routes had NO escaper at all, and the 2 that did (accounting + reports/eod) only handled RFC 4180 quoting β none defanged formula injection (
=,+,-,@prefix). Real attack: patient submits firstName=HYPERLINK("http://attacker/","Click here")during booking. Doug exports patients to CSV. Doug opens it in Excel. Excel renders the formula as a clickable phishing link inside Doug's spreadsheet β patient just achieved client-side execution on the admin's machine.=1+1+CMD|'/c calc'!A1can run shell commands on Windows. New sharedsrc/lib/csv.tswithcsvSafe()/csvRow()/csvBuild()helpers β single-quote prefix on cells starting with[=+\-@\t\r](OWASP recommended mitigation) PLUS RFC 4180 escaping. All 6 routes (patients/export,appointments/export,audit-log/export,reports/export,accounting/export,reports/eod/export) now route through the shared helper. The 4 unprotected routes were the highest risk because they include patient-controlled data (firstName, lastName, address); the 2 with partial protection were lower risk but still patched. tsc clean.
v2.75.192026-05-08ProductionFixed
- π‘οΈ **
/api/patient/auth/logoutβ cookie not reliably cleared.** Cross-handler consistency scan of the 4 logout routes (admin, patient, provider, dispensary) flagged this one as the odd-one-out: it usedcookies.delete(PATIENT_SESSION_COOKIE)while the other 3 usedcookies.set(name, "", { maxAge: 0, path: "/" }). Per Next.js docs,cookies.delete(name)without explicit path only matches cookies on the request's current path β and the patient login route sets the cookie withpath: "/". So in some browsers / Next.js routing scenarios, the logout call would fail to actually clear the cookie, leaving the user appearing logged in after a logout (until JWT exp kicked in). Now matches the 3-other-logout-routes pattern:set(name, "", { maxAge: 0, path: "/" }). tsc clean.
v2.75.182026-05-08ProductionFixed
- π‘οΈ **
lib/twilio.tsβ incomplete-config crash class.** Grep for non-null-asserted env reads (process.env.X!) caught:client.messages.create({ body, from: process.env.TWILIO_PHONE_NUMBER!, to }). The Twilio client was constructed onTWILIO_ACCOUNT_SID + AUTH_TOKENalone βTWILIO_PHONE_NUMBERwas never validated. If env was misconfigured (SID + TOKEN present but PHONE_NUMBER missing), every send crashed with a Twilio 400 logged as[SMS] Twilio error:β opaque during an incident. Now: gate the client at module load on all three env vars + type the constantTWILIO_FROMso the call site passes the validated value (no more!assertion). An incomplete config now logs once at module load:[SMS] Skipped β Twilio env incomplete (need SID + TOKEN + PHONE_NUMBER)instead of N runtime crashes per send. Verified Postmark + SF are correctly gated already (activeProvider()/getAccessToken()callers check the env var before invoking) so their!assertions are safe.
v2.75.172026-05-08ProductionFixed
- π‘οΈ **
import "server-only"added to 7 secrets-bearing libs.** Defense-in-depth against accidental client-bundle leaks. Pre-fix, none ofemail.ts,twilio.ts,salesforce.ts,practicefusion.ts,workflow.ts,audit.ts,db.tshad theserver-onlymarker β meaning if a future client component accidentally imports any of them (intentionally or via tree-shake error), Next.js silently bundles the secret-laden module into client JS where it's reachable by anyone viewing the page source. Each contains real secrets: email =RESEND_API_KEY/POSTMARK_API_KEY/ SES creds; twilio =TWILIO_AUTH_TOKEN; salesforce = OAuth client secret; practicefusion =PF_API_KEY; workflow wraps email + sms; audit + db = Neon DB url. Withserver-onlyimport at the top, any client-component import of these libs now throws at build time, surfacing the leak before it ships. Verified zero client components currently import these (grep across^"use client"files for the 7 libs returned 0 matches). Skipped*-session.tslibs because they have legitimate type-only client imports (e.g. AdminNav.tsx importstype { AdminRole }); those would need a types-vs-runtime split to mark server-only, which is a larger refactor. tsc clean.
v2.75.162026-05-08ProductionFixed
- π‘οΈ **
/api/admin/mailing/labelsβ bulk PHI egress missed the v2.74.27 phi-egress audit cluster.** Cross-handler scan of admin GET routes returning PHI without audit caught this β generates a PDF with N patient names + addresses (Avery 5163 mailing labels) and streams it to the browser, but never logged the event. Same class of bulk-PHI-egress as CSV export (EXPORT_PATIENTS), but missed when the phi-egress synthetic filter was set up. Now:EXPORT_PATIENTSaudit fires withsurface=mailing-labels labels=N scope=ids|all-unmailedso a HIPAA reviewer asking 'show me everything that left our environment' picks up label printings alongside the CSV exports + SF/PF syncs + dispensary access. Pre-existing audit log rows pre-fix don't capture historical label prints; only new prints land in the trail. tsc clean.
v2.75.152026-05-08ProductionFixed
- π‘οΈ **Two more provider-portal PHI access routes had no audit row.** Cross-handler consistency scan of
/api/provider/*for token-validation pattern surfaced two routes that gate on portalToken correctly but never log the access. (1)/api/provider/documents/[id]β provider downloads a patient-uploaded document (intake form, medical records, photo ID, inbound-email attachments promoted to records). HIPAA forensic question 'which provider saw which patient's documents and when' was unanswerable. NowVIEW_PATIENTaudit withkind=document-download docId=+ provider name in detail; resourceId is the patientId so all rows about a single patient cluster. (2)/api/provider/cert-preview/[appointmentId]β provider previews the about-to-be-issued cert PDF (patient name + DOB + address + conditions baked in). Different event class thanDOWNLOAD_CERT(which is for finalized certs); useVIEW_APPOINTMENTwithkind=cert-previewto disambiguate. With this commit every provider-portal route that touches PHI carries an audit trail. tsc clean.
v2.75.142026-05-08ProductionFixed
- π‘οΈ **Cron auth-bypass when CRON_SECRET unset β closed across all 14 cron routes via centralized helper.** Critical pre-existing bug class: every cron route inlined
req.headers.get('authorization') !== \Bearer ${process.env.CRON_SECRET}\`β when the env var is unset, the comparison becomes!== 'Bearer undefined'. An attacker sendingAuthorization: Bearer undefinedliteral would bypass the check. Plus the===short-circuits on length mismatch (length-disclosure timing leak β same class as v2.75.13 SF-webhook fix). NewverifyCronAuth(req)helper atsrc/lib/cron-auth.tsdoes: (1) fail-closed when CRON_SECRET is unset (noBearer undefinedbypass), (2) acceptsx-vercel-cron: 1header as the primary cron signal, (3) Bearer Authorization withtimingSafeEqual, (4)x-internal-secret` header alternative for legacy internal callers. All 14 cron routes refactored to call the helper instead of inlining the check. Closes the bypass + the timing leak + the inline-pattern drift in one centralized fix. tsc clean.
v2.75.132026-05-08ProductionFixed
- π‘οΈ **Salesforce webhook secret comparison was the only
===check across all 5 webhooks β bringing it to constant-time parity.** A consistency-grep acrosssrc/app/api/webhooks/*/route.tsfor secret/auth patterns showed 4 webhooks (Stripe viaconstructEvent, RingCentral Γ 2 viatimingSafeEqual, Postmark inbound viatimingSafeEqual, Twilio viavalidateRequest) all use constant-time comparison; only Salesforce used plain===. JS string===short-circuits on length mismatch β leaks the secret length to an attacker who can time response. With a 32+ char random secret the absolute leak is minor (a 4-byte timing skew over many requests can confirm length only), but parity across all 5 webhook handlers eliminates the inconsistency. NewverifySecret(provided)helper uses Buffer + timingSafeEqual, fail-closed when env var unset (matching the other handlers' pattern). tsc clean.
v2.75.122026-05-08ProductionFixed
- π‘οΈ **Defense-in-depth
on all four protected surfaces.** robots.txt hasDisallow: /admin /provider /dispensary /patient(advisory only β well-behaved bots respect it; rogue scrapers and accidental link-leaks bypass it). Per-page meta robots stops indexing at the page-render level, neutralizes social-card preview unfurlers (Slack / Twitter cards / Discord), and provides a second signal Google's actually-respected layer. Implementation: layout-level Metadata export withrobots: { index: false, follow: false, nocache: true }cascades to every nested page. Added to:/admin/layout.tsx(existed, added metadata),/provider/layout.tsx(created),/dispensary/layout.tsx(created),/patient/layout.tsx(created). Two pre-existing per-page metadata declarations (/admin/compliance/page.tsx,/provider/training/page.tsx) become redundant but harmless. Public booking flow (/,/book,/providers/*,/locations/*) is unaffected β those legitimately want to be indexed. tsc clean.
v2.75.112026-05-08ProductionFixed
- π‘οΈ **Public schema input bounds β closes DB-bloat / email-render-bomb vector on the two public-facing POST surfaces.** Grep for
z.string().min(N)without.max(N)flaggedBookingSchema(/api/appointmentsPOST) andWaitlistSchema(/api/waitlistPOST). Both took user input withmin(N)only β Vercel's 4.5MB body limit was the only ceiling, meaning a hostile actor could submit a 100KB firstName, 4MB conditions array, etc, and the row would land in DB + every subsequent email rendering. Bounds added (RFC/USPS-aware): firstName/lastName β€100, email β€254 (RFC 5321), phone β€20, dob β€10 (YYYY-MM-DD), address β€500, conditions array β€20 entries Γ 100 chars each, IDs (slot/location/payment-intent) β€40-120. Generous but well below platform limits. Pre-existing DB rows unaffected β bounds only apply to new submissions. tsc clean.
v2.75.102026-05-08ProductionFixed
- π‘οΈ **PHI leak in audit detail strings β
/api/admin/messages/sendwas logging patient firstName/lastName + email subject + SMS body fragments to the audit log.** A scan for the longest interpolated audit detail strings (detail:.*${.*}.*${) flagged this route's twoaudit('BULK_SEND', ...)calls. Both leaked PHI-adjacent content into the trail: (1) email path interpolated${patient.firstName} ${patient.lastName}: ${subjectLine}(subject can be 'your Hep C result' etc, plus direct identifiers), (2) SMS path interpolated${recipientLabel}: ${body.slice(0, 80)}(staff-typed SMS body can be PHI like 'your test came back...'). HIPAA audit logs should record metadata (who, when, channel) β never content. The PatientMessage row created in the same handler already holds the full content for forensic queries; audit row is metadata-only by design. Detail strings now recordchannel=EMAIL by=adminName attachments=Nandchannel=SMS by=adminName matched=yes|no. resourceId still names the patient via opaque ID. Pre-existing audit rows in the DB still carry the leaked content; only new writes are clean (consider a one-shot SQL UPDATE to redact historical rows if a HIPAA audit is imminent). tsc clean.
v2.75.92026-05-08ProductionFixed
- π **Email-with-whitespace login bug β admin / provider auth now
.trim().toLowerCase()like patient auth already did.** A grep foremail.toLowerCase()vs.trim().toLowerCase()across all auth routes turned up an inconsistency: patient auth normalizes input correctly (handles paste-with-whitespace), admin / provider auth did not. Real UX bug β admins or providers pasting their email from a password manager or autocomplete suggestion that included a trailing space would get 'Invalid credentials' on the first try, retry, and eventually succeed when they noticed the whitespace. Four routes fixed:/api/admin/login,/api/provider/auth/login,/api/admin/forgot-password,/api/provider/forgot-password. Each now normalizesrawEmailβString(rawEmail).trim().toLowerCase()at the gate before any DB lookup or rate-limit key generation. Patient auth was already correct (already handling this since v2.74.x). tsc clean.
v2.75.82026-05-08ProductionFixed
- π‘οΈ **TCPA round 1 β
sendSmsnow auto-appends STOP language at the helper level (mirrors v2.75.4's logWorkflowEvent and v2.75.7's email-side fix).** A grep forsendSms(calls vsSTOP|opt outin body text turned up four call sites sending inline SMS without the TCPA-required opt-out language: (1)admin/appointments/[id]/statusno-show SMS ('We missed you today, ${firstName}. Reply or call...'), (2)admin/outreachadmin-typed bulk-SMS body, (3)appointments/reschedule(uses smsBookingConfirmation template which DOES have STOP β false positive), (4)admin/messages/sendstaff-typed body. Per CTIA / 10DLC compliance, every promotional SMS to a US recipient must include opt-out language. Helper-level fix:ensureStopLanguageappendsReply STOP to opt out.if the body doesn't already contain\bstop\b(case-insensitive). Idempotent β templates inlib/emails.tsthat already have the language are unchanged. Closes 3+ real gaps in one helper edit. tsc clean.
v2.75.72026-05-08ProductionFixed
- π‘οΈ **CAN-SPAM drift round 9 β
/api/admin/outreachwas the FIFTH path with the same partial gap.** Outreach correctly *gated* onpatient.emailUnsubscribed(skipped opted-out recipients) but the actual sends to non-opted-out patients had NO unsubscribe link in body and NO List-Unsubscribe header. CAN-SPAM requires every marketing email to include a one-click opt-out β the inline link is what lets the recipient unsubscribe going forward, even if they're currently subscribed. Without it, any marketing blast Doug sends from the outreach surface is non-compliant. Now:buildHtmltakes anunsubUrlarg and renders the inline footer link, andsendEmailis called with{ unsubscribeUrl: unsubUrl }so the List-Unsubscribe + List-Unsubscribe-Post headers fire (RFC 8058 + Gmail bulk-sender requirements). Per-patient URLs are signed (makeUnsubUrl(patient.email)) so each recipient gets their own one-click endpoint. tsc clean.
v2.75.62026-05-08ProductionFixed
- π‘οΈ **CAN-SPAM drift round 8 β
/api/cron/waitlistwas the FOURTH duplicated waitlist-notify path.** A grep forfont-family:Georgia,serif(the inline-template signature) turned upcron/waitlist/route.tsβ a daily cron that mass-sends 'Slots available β book before they fill up' to every unnotified waitlist entry when slots are open. Same compliance gap as the prior three fixes: no emailUnsubscribed gate, no List-Unsubscribe header, no body footer link, no audit row. Now: pre-fetches the unsubscribed-email set in one query (case-insensitive against Patient table), skips matching entries + marks them notifiedAt to unblock the queue (same queue-block-avoidance as v2.75.5), adds inline footer + List-Unsubscribe header on the actual sends, and audits BULK_SEND withactor=cron skippedUnsub=Nso the daily cron run is observable. With this commit ALL FOUR waitlist-notify paths (admin bulk, admin per-entry, system-internal cancel, daily cron) carry compliant email + audit. tsc clean.
v2.75.52026-05-08ProductionFixed
- π‘οΈ **CAN-SPAM drift round 7 β
notifyWaitlistwas the third duplicated waitlist-notify path, missing the same defenses I fixed in v2.74.28 + v2.74.31.** Called internally from/api/appointments/cancelwhen a patient's cancellation frees up a slot βnotifyWaitlistinsrc/lib/workflow.tswas sending the 'A slot just opened β book now' marketing email with NO unsubscribe gate, NO List-Unsubscribe header, NO body unsubscribe link. Same compliance bug Γ every cancellation. Now: pre-fetches Patient with matching email + emailUnsubscribed=true; if matched, marks the waitlist entry as notified (so the next cancellation picks up the next eligible entry β pre-commit Explore review caught a queue-block bug in the first iteration wherereturn;withoutnotifiedAtupdate would loop forever on an unsubscribed head-of-queue, blocking patients at position 2+). When sending, adds inline unsubscribe footer + List-Unsubscribe header via existing helpers. With this commit all three waitlist-notify paths (admin bulk, admin per-entry, system-internal) carry compliant email. tsc clean.
v2.75.42026-05-08ProductionFixed
- π‘οΈ **
logWorkflowEventβ silent-error class fixed at the helper level (centralizes the v2.74.x send-renewal lesson across all 30 callers).** A grep for.catch(() => {})patterns insrc/app/apiturned up two genuine drift cases (appointments/cancel + reschedule) where workflow-log failures were silently swallowed β the same class of bug the v2.74.x send-renewal fix called out (FK violation / connection-pool exhaustion / schema-mismatch invisible to the auditor). Audit at the helper level (rather than per-call-site) catches all 30 callers including future ones. NowlogWorkflowEventmirrors theaudit()helper pattern: try/catch wrapping the DB write, never throws, surfaces failures in Vercel logs as[workflow-log-failed] type=X channel=Y patient=Z err=.... Side benefit: routes that previously had no.catchand would 500 on a workflow-log failure (the worse failure mode β patient gets an error AFTER the email already sent) now also continue gracefully. tsc clean.
v2.75.32026-05-08ProductionFixed
- π‘οΈ **
/api/my-appointments/[token]/documentsPOST β per-token rate limit added.** Patient document upload was token-gated but had no rate limit, so a leaked portalToken could be used to upload 10MB PDFs in a tight loop and burn through Vercel Blob storage quota. Cap is 10 uploads per 5 min β legit patients upload a few records ahead of a visit; abuse is the only way to hit that. SamecheckRateLimitpattern as the v2.74.2-4 patient-token routes (cancel / confirm / checkin). Returns 429 'Too many uploads' on cap. tsc clean. - π **
flow.greenwellness.orgadded as app subdomain.** Added in Vercel project + GoDaddy A recordflow β 76.76.21.21, DNS propagated within 30s. Vercel auto-issues SSL once verification completes. The full DNS_CUTOVER.md plan still targets the apexgreenwellness.org(replacing the old WordPress + Sucuri site) βflow.is an interim app URL that doesn't disturb the existing apex.
v2.75.22026-05-08ProductionFixed
- π‘οΈ **Admin login was missing timing-attack defense β biggest auth security gap closed.** A scan across all four login routes (admin, patient, provider, dispensary) found
/api/admin/loginhad noDUMMY_HASHconstant-time-compare fallback. The other three routes (added in v2.74.6/.7) all runbcrypt.compare(password, DUMMY_HASH)against a fake hash when no user matches, so response time is identical whether the email exists or not. Admin login was returning fastif (!user) return 401β letting an attacker enumerate the admin roster via timing side-channel (no-user β ~1ms 401, real user β ~100ms bcrypt β slower 401). This is a top-of-list HIPAA-relevant attack: knowing valid admin emails is step one for credential stuffing or targeted phishing. Now: always run bcrypt against the real hash ORDUMMY_HASH, then checkif (!user || !user.isActive || !passwordMatch)β TS narrowsuserto non-null after the early-return, all three failure modes return identical timing. Plus cost-factor harmonization: patient + provider + admin login DUMMY hashes bumped from$2b$10$to$2b$12$to match the realbcrypt.hash(password, 12)cost everywhere in the codebase (a cost-10 dummy was itself a smaller leak β fast dummy ~25ms vs slow real ~100ms). Dispensary kept at cost 10 (its real hashes are also cost 10; bumping would create an opposite-direction leak against existing dispensary users). tsc clean.
v2.75.12026-05-08ProductionAdded
- π **/admin/audit-log β three header-tile counters for the canonical reviewer questions.** Existing 'Exports today' (amber) joined by 'PHI egress today' (indigo, broader: CSV + SF/PF + dispensary) and 'Auth events today' (blue: 4 LOGIN + 3 PASSWORD_CHANGE). Each tile is a one-click drilldown to its matching synthetic filter (
?action=exports|phi-egress|auth-events). Tiles hide when count=0 so the header stays clean on slow days. The three render in priority order β PHI egress first (most security-critical), auth events second (login spike = credential stuffing signal), CSV exports third (subset of PHI egress, kept for backward compat). Plus the existing exports tile renamed 'CSV export(s) today' to disambiguate from the broader PHI egress one. Same query shape as the existing tile (singleauditLog.countper category, batched into the same Promise.all). With this commit a HIPAA reviewer can answer the three canonical questions ('what PHI left today', 'who logged in today', 'what got CSV-exported today') without leaving the audit log header.
v2.75.02026-05-07ProductionAdded
- π‘οΈ **Migration-drift detection in
/api/health.** Doug 2026-05-07 post-VRG-/dashboard-500 incident (/CODE/INCIDENTS.md): the recurring class-of-bug across all DB-bearing apps is deploy-without-migration where the ORM client expects columns the prod DB lacks. NEWsrc/lib/migration-drift.tsreturns a structured{ ok, mode, appliedCount, expectedCount, pending }shape. **Mode-aware:** GW currently usesprisma db push(no committed migrations), so the helper returnsmode: 'schema-push'+ ok=true (drift detection N/A). If/when GW switches to migrations (prisma migrate dev+ commitprisma/migrations/), the helper auto-flips tomode: 'migrations'and starts catching real drift β no code changes here./api/healthnow returns 503 when drift detected. Sister commits on VRG v9.5.0, Inventory App v182.945, CannAgent v3.87.
v2.74.312026-05-07ProductionFixed
- π‘οΈ **CAN-SPAM drift round 6 β
/api/admin/waitlistPOST (per-entry notify) was the analog to v2.74.28's notify-all fix.** Same shape: staff clicks 'notify' on an individual waitlist row β sends marketing email ('A slot just opened β book now') with no unsubscribe link, no List-Unsubscribe header, no patient.emailUnsubscribed gate. Compliance leak Γ every individual notify click. Now: per-entry email-existence check returns 409 withreason=unsubscribedif the recipient is opted out (so staff sees 'patient has unsubscribed' instead of silent send). When sending, adds inline unsubscribe footer + List-Unsubscribe header. Plus previously-missing BULK_SEND audit row withkind=singleanddismissflag β distinguishes staff-clicked-notify from staff-clicked-dismiss in the trail. With this commit both waitlist surfaces (bulk + single) carry compliant email + audit. tsc clean.
v2.74.302026-05-07ProductionFixed
- π¨ **/admin/audit-log β labels for 20 pre-existing actions that rendered as raw enum strings.** Coverage gap surfaced by a comm(1) diff between the AuditAction union and ACTION_LABELS map: 20 actions had no human label (
SMOKE_TEST_BLOB,EXPORT_ACCOUNTING,VIEW_LOGIN_HISTORY,RESEND_BOOKING_CONFIRMATION,SEND_RENEWAL_REMINDER,BULK_SEND_RENEWAL_REMINDERS,UPDATE_APPOINTMENT_NOTES, etc.) and the dropdown fallback?? arendered them as the raw all-caps enum. Now every audit action in the union has a sentence-case human label. Pure UX polish β no functional change.
v2.74.292026-05-07ProductionAdded
- π **/admin/audit-log β third synthetic filter: 'Auth events'.** Combines 4 LOGIN actions (
ADMIN_LOGIN,PATIENT_LOGIN,PROVIDER_LOGIN,DISPENSARY_LOGIN) + 3 PASSWORD_CHANGE actions across all user types into one URL-shareable filter (?action=auth-events). Answers two security-triage questions at once: 'who logged in today?' and 'who reset whose password?'. The canonical compromise signal β a forgot-password request for an ADMIN account immediately followed by an ADMIN_LOGIN from a new IP β now surfaces in a single filter. Plus the underlying action dropdown now lists all three synthetics (CSV exports / PHI egress / Auth events) so submitting the date range while a synthetic is active doesn't reset the filter. With this commit the audit-log surface offers reviewer-class one-click pivots for the three canonical HIPAA forensic questions: 'who saw what PHI', 'what data left our environment', and 'who logged in / changed credentials'.
v2.74.282026-05-07ProductionFixed
- π‘οΈ **TCPA / CAN-SPAM drift round 5 β 2 more leaks closed.** A scan for
sendEmail/sendSmscalls withoutemailUnsubscribed/smsConsentguards turned up two real drift cases (post v2.73.31-32 fixes). (1)/api/admin/waitlist/notify-allβ bulk waitlist nudge with subject 'Slots available β book before they fill up' was sending marketing email with NO unsubscribe link in body, NO List-Unsubscribe header, AND no cross-check against patient.emailUnsubscribed. CAN-SPAM violation Γ N waitlist entries. Now: pre-fetches the unsubscribe set in one query (case-insensitive email lookup against Patient table), skips matching waitlist entries with askippedUnsubcounter, adds inline unsubscribe footer to email body, passes unsubscribeUrl to sendEmail (which handles List-Unsubscribe header). Audit detail logsskippedUnsub=Nso a spike in opt-outs surfaces in the audit log. (2)/api/admin/patients/[id]/send-renewalβ staff-triggered renewal email (winBack / reEngagement templates, marketing-class) was sending to opted-out patients silently. Now returns 409 withreason=unsubscribedso the UI can surface 'patient has unsubscribed' instead of silent success. Plus added previously-missing SEND_RENEWAL_REMINDER audit on the staff-triggered surface (cron path was already audited). tsc clean.
v2.74.272026-05-07ProductionAdded
- π **/admin/audit-log β new 'PHI egress (all)' synthetic filter pill.** One-click view that answers the canonical HIPAA reviewer question 'show me everything that left our environment'. Combines
EXPORT_PATIENTS+EXPORT_APPOINTMENTS+EXPORT_ACCOUNTING+EXPORT_AUDIT_LOG(CSV egress) +SF_LEAD_SYNCED+PF_PATIENT_SYNCED(partner-system PHI sync, v2.74.21) +DISPENSARY_CERT_ACCESS(B2B partner cert access, v2.74.24). The existing 'All exports' pill renamed to 'CSV exports' to disambiguate. The two pills nest cleanly: PHI egress is a strict super-set of CSV exports. Same?action=phi-egressURL-shareable pattern as the existing synthetic filter.
v2.74.262026-05-07ProductionAdded
- π‘οΈ **Operational + financial admin audit gaps closed (7 routes, 2 new audit actions).** New
PROMO_CODE_UPDATE+CAPACITY_UPDATEactions cover the previously-unaudited operational + financial admin surfaces. **Promo codes** (/api/admin/promo-codesPOST + PATCH + DELETE) β every code is a discount applied at checkout, financial impact per row. Detail logskind=create|update|delete code=NEW2026 discount=2500 maxUses=...; DELETE captures pre-delete state. Yellow pill (financial family, alongside CERT_REQUEST_UPDATE). **Capacity / schedules** (/api/admin/schedulesPOST + DELETE,/api/admin/slots/{generate,clear,single,manage}) β provider availability + slot CRUD. Affects every booking decision in the system. A reviewer asking 'did anyone clear the slots before that no-show spike?' can now answer it. Detail logskind=schedule-create|schedule-delete|slots-generate|slots-clear|slot-single-create|slots-manage-deleteplus provider id + date window + count. Slate pill (operational family, distinct from financial yellow). With this commit every admin write surface that affects either money or capacity carries an audit row. tsc clean.
v2.74.252026-05-07ProductionFixed
- π‘οΈ **Four more admin-side audit gaps closed (highest-stakes admin actions).** (1)
/api/admin/providers/signatureβ admin replaces a provider's signature image, which stamps every cert PDF that provider issues going forward. The provider-self path was audited (PROVIDER_SELF_UPDATE) but the admin override path was not β exactly the path a malicious actor would use to forge certs. NowUPDATE_PROVIDER kind=signature replaced-by-admin. (2)/api/admin/providers/portal-linkPOST + DELETE β generates / revokes the bearer token that gates the entire provider portal (cert issuance + signature + profile). Token rotation is auth-critical: anyone with the old link is locked out, anyone with the new link is in. Both paths now auditUPDATE_PROVIDER kind=portal-token-issued|portal-token-revoked. Token itself is never logged. (3)/api/admin/waitlist/notify-allβ bulk email to the entire active waitlist with a 'slots open' nudge. Same audit shape asBULK_SENDwithsurface=waitlist channel=email scanned/notified/skippedcounts so unusually large blasts stand out. (4)/api/admin/cert-requestsPOST + PATCH + DELETE β paid PHI-touching tickets (RESEND $25, ADDRESS_CHANGE $50, etc); each row is a $25-$50 patient charge plus a clinical-data touch. NewCERT_REQUEST_UPDATEaudit action covers create/update/delete withkind=create|update|deleteflag. DELETE captures the pre-delete state (patient + type + status + amount) since the row is destroyed. tsc clean.
v2.74.242026-05-07ProductionFixed
- π‘οΈ **Dispensary partner PHI access β the largest remaining audit-trail gap, now closed.** Two BAA-gated routes were silently serving PHI to dispensaries with zero forensic trail: (1)
/api/dispensary/certsreturns names + DOB + conditions for every patient with active dispensary consent (the dispensary's queue view); (2)/api/dispensary/cert/[token]streams the actual cert PDF to the dispensary. Both audit=0 β 'which patients did this dispensary look at last week' was unanswerable, which is the canonical HIPAA forensic question for B2B partners. NewDISPENSARY_CERT_ACCESSaudit action (indigo pill, same family as SF/PF PHI exports β together they form the 'what data left our environment' cluster). LIST audit fires on every queue view withkind=list count=N; DOWNLOAD fires only after blob fetch succeeds (no false positives on 502s) withkind=download appointmentId=X. resourceId on LIST is the dispensary id, on DOWNLOAD is the appointment id, so a reviewer can pivot either direction. tsc clean.
v2.74.232026-05-07ProductionAdded
- π **Migration cockpit β 2 new tiles for the v2.74.21 partner-sync audit channel.**
/admin/migrationaudit-channel grid now showsSF lead syncs (7d)+PF patient syncs (7d)alongside the existing tiles (recording playback, RC failures, RC recreations, exports today, orphan links). The SF tile is the canonical Stage-5 retirement signal: as bookings stop creating SF leads (because we've turned off SF integration), this number should trend to zero. The PF tile should hold steady while PF remains our EHR. Both deeplink to/admin/audit-log?action=so a reviewer clicks once to see the underlying rows. Grid widens from 5 β 7 tiles; lg:grid-cols-5 wraps to a 5+2 layout on wide screens.
v2.74.222026-05-07ProductionFixed
- π‘οΈ **3 more audit gaps closed: cron-driven contactFlag + mailing attestation.** (1)
/api/cron/renewalsβ when the daily renewals cron escalates a patient who's hit β₯4 outreach attempts with no rebook, it auto-setscontactFlag=trueto suppress further automated messages. Without an audit row, 'why did this patient stop receiving renewal reminders?' is unanswerable. Mirrors the existing staff-driven contactFlag audits in/api/admin/patients/flag+/log-call. actor=cron flag distinguishes from staff-initiated. (2+3)/api/admin/mailingPATCH + POST β staff marks cert envelopes as physically mailed (mark_mailed / unmark_mailed / bulk_mark_mailed). Mailing is the patient-facing physical proof of cert issuance; an attestation that a cert went in the mail is something a reviewer should be able to trace. ReusesUPDATE_APPOINTMENT_NOTESaction withsurface=mailingflag (keeps the audit dropdown short β same shape, different sub-context). Bulk audit captures requested-vs-updated count diff to surface idempotent skips for already-mailed entries. tsc clean.
v2.74.212026-05-07ProductionAdded
- π‘οΈ **Two new audit actions for PHI-export-to-partner-systems.**
SF_LEAD_SYNCED+PF_PATIENT_SYNCEDclose the success-side of the existing FAILURE pair (SF_INTEGRATION_FAILURE/PF_INTEGRATION_FAILURE). Both wired into/api/integrations/salesforce+/api/integrations/practicefusionβ the routes that push patient identity (firstName, lastName, dob, address, phone, email) and appointment metadata to SF / PF when a booking lands. Until now those exports left no trail; HIPAA forensic question 'which patients had records pushed to PF/SF and when' was unanswerable. New audit fires only on actual exports (new lead created OR new PF patient/appointment created β reusing existing identifiers is not a new export). Detail recordspatient=so a reviewer can correlate with PF/SF audit on their side. Indigo pill inexternal= newPatient= /admin/audit-logdistinguishes them from the red FAILURE family.
Fixed
- π‘οΈ **
/api/admin/appointments/[id]/statusβ CONFIRMED branch was the partial-coverage gap.** Route had audit on COMPLETED + CANCELLED + NO_SHOW (3 of 4 status transitions); the manual CONFIRMED flip from admin was unaudited. AddedCONFIRM_APPOINTMENTaudit on the sameipargument the other branches use. With this commit every status transition through the admin status route carries a row.
v2.74.202026-05-07ProductionFixed
- π‘οΈ **External + system-driven appointment mutations now audited.** Two more audit gaps closed in the systematic sweep: (1)
/api/webhooks/salesforceβ Salesforce Flow can flip an appointment to COMPLETED (with certExpiryDate stamp on the patient) or NO_SHOW via HTTP callout. Both events were silently mutating Appointment + Patient rows with no trail. Now auditsCOMPLETE_APPOINTMENT/NO_SHOW_APPOINTMENTwithactor=salesforce-webhookflag + the originating sfLeadId so a HIPAA reviewer can trace data lineage to the SF event that triggered it. Until Stage-5 SF retirement closes this surface, this is the only attribution for SF-driven completions. (2)/api/cron/no-showβ hourly cron flips appointments to NO_SHOW after 30min of no check-in. Mirror of the provider/admin no-show audit, withactor=cron reason=auto-30min-elapsedflag distinguishing automated from human-initiated. The audit() helper handles cron context (no request scope) by catchingheaders()exceptions, so the call is safe outside a request. tsc clean.
v2.74.192026-05-07ProductionFixed
- π‘οΈ **Five more audit gaps closed β patient-token + provider-bulk paths.** Sweep continued from v2.74.18: scanned every route mutating Appointment + Patient and ranked by audit-count vs mutation-count. (1)
/api/provider/bulk-approveβ bulk version of the cert-issuance flow audited yesterday; same risk Γ N appointments per click. AddedAPPROVE_APPOINTMENTper cert withbatch=trueflag in detail so a reviewer can spot bulk events (which deserve extra scrutiny β many certs at once). (2)/api/appointments/cancelβ patient-driven cancel via emailedcancelToken. Triggers Stripe refund + FHIR cancel + waitlist notify; high-impact mutation that previously left no patient-attributed trail. Detail recordsactor=patient,eligibleForRefund,stripePaymentso a reviewer can spot last-minute cancels and refund anomalies. (3)/api/appointments/confirmand (4)/api/checkin/[token]β both flip status to CONFIRMED via the samecancelToken. SameCONFIRM_APPOINTMENTaction withsurface=email-linkvssurface=checkinflags to disambiguate. (5)/api/patient/auth/forgot-passwordβ reset-link request for an active patient account. Mirror of admin/provider forgot-password β the request itself is an attack signal worth logging (bad actor probing email enumeration). Detail =kind=forgot-requested. All five paths useactor=patientor provider-name attribution since the routes don't carryx-admin-id. tsc clean.
v2.74.182026-05-07ProductionFixed
- π‘οΈ **
/api/provider/actionβ biggest single audit gap in the system, now closed.** Provider-portal route handling cert issuance + no-show + video-link change hadaudit=0while mutating both appointment + patient rows. Cert issuance specifically flips appointment to COMPLETED, writes a signed PDF to private blob storage, and stamps the patient's record withissuingDoctor+certExpiryDateβ the canonical clinical decision in the system, and the most-scrutinized event in any HIPAA audit. Without a trail, 'show me every cert this provider issued' was unanswerable. Three audit calls added: (1)APPROVE_APPOINTMENTafter the cert-issuance transaction, detail records provider name + patient id + expiry date +cert=issued|skipped(PDF generation is best-effort and proceeds without a cert if blob upload fails β thecert=skippedflag surfaces that anomaly to a reviewer); (2)NO_SHOW_APPOINTMENTafter the status flip, detail records provider + patient; (3)RESCHEDULE_APPOINTMENTafter the video-link write β a swapped link could redirect a telehealth visit off our BAA-covered surface, so the change needs attribution. Each audit row usesprovider=in the detail (provider-portal traffic doesn't carry thex-admin-idheader that auto-attributes admin actions, so attribution lives in the detail field β same convention asPROVIDER_LOGINandPROVIDER_SELF_UPDATE). With this commit the provider portal's three PHI-affecting writes all carry audit. tsc clean.
v2.74.172026-05-07ProductionFixed
- π‘οΈ **
/api/admin/appointments/video-linkβ last appointment-mutation route missing audit.** Surfaced by a coverage scan: 16 admin appointment routes had audit, 1 didn't. Video-link changes directly affect where the patient lands for a telehealth visit; if a malicious admin (or innocent typo) ever points the link to the wrong destination, the audit row is the trail. Reuses existingRESCHEDULE_APPOINTMENTaction β same shape (admin-driven appointment-touching change), keeps the dropdown short. Detail recordsfield=videoLink value=set|cleared(the URL itself isn't logged β could leak the Doxy.me waiting-room slug into the audit row, which is admin-only but still unnecessary). With this commit every appointment-mutation route under/api/admin/appointments/audits. tsc clean.
v2.74.162026-05-07ProductionFixed
- π‘οΈ **Highest-privilege admin actions β staff + provider create/update audit gap.** Both
/api/admin/usersand/api/admin/providershad POST + PATCH paths writing without audit. These are the canonical 'compromised admin β backdoor' surfaces: a malicious admin who got a foothold can create a new ADMIN-role staff account for persistent access OR onboard a fake provider with PHI access across the patient roster. Without audit, neither path leaves a trail. NewCREATE_ADMIN_USER,UPDATE_ADMIN_USER,CREATE_PROVIDER,UPDATE_PROVIDERaudit actions (red + rose pills β distinct from all other categories so escalations are impossible to miss when scanning the audit log). Detail records the new/updated entity's email + role (admin) or name + title (provider), with explicit flags pulled out for the highest-signal subsets:role=ADMIN(escalation),active=false(deactivation),password=reset-by-admin(admin overriding another's password). 4 routes Γ 2 paths = the staff/provider trail is now uniformly audited. tsc clean.
v2.74.152026-05-07ProductionFixed
- π‘οΈ **Admin dispensary management β missing audit on create + update.** Two HIPAA-critical mutations on
/api/admin/dispensarieswriting without trail. (1) PATCH togglesbaaSignedAt(gates the entire dispensary's cert access) +isActive(cuts off access entirely) + name/phone. (2) POST creates a new dispensary + initial user account. NewCREATE_DISPENSARY(emerald) +UPDATE_DISPENSARY(cyan) audit actions, distinct colors so partner-facing activity is scannable separately from patient-side rows. PATCH detail names changed fields plus BAA / active explicit values when toggled (baa=signed|cleared active=true|false); CREATE detail records dispensary name + first user's email so a reviewer can spot misconfigured creates without joining tables. tsc clean.
v2.74.142026-05-07ProductionFixed
- π‘οΈ **Patient notes + Site settings β missing audit on admin writes.** Two more mutation routes silently writing without audit. (1)
/api/admin/patients/notesβ admin-staff observations on patient records (clinical context, hard-to-reach signal, staff handoff notes). DirectstaffNotescolumn write with no trail. Now writesUPDATE_PATIENTwith detailfield=staffNotes len=Nβ records the length but not the body itself, keeping any PHI in the notes out of the audit row while preserving the 'did notes actually change + to what size' forensic signal. (2)/api/admin/settingsβ public-facing announcement banner + Google review URL. Banner shows on every homepage load. NewSITE_SETTINGS_UPDATEaudit action (purple per-row pill, distinct from indigo password / blue login / green/amber other categories). Detail records changed field names (no banner body β could be long marketing copy). If a misleading or compromised banner ever appears, the audit row tells you who set it and when. tsc clean.
v2.74.132026-05-07ProductionFixed
- π‘οΈ **Admin patient flag + log-call routes β missing audit on contactFlag toggles.** Two flag-toggle sites missed today's audit sweep. (1)
/api/admin/patients/flag(manual hard-to-reach toggle in the patient detail) wrote the column without an audit row. Now writesUPDATE_PATIENTwith detailfield=contactFlag value=true|false. (2)/api/admin/patients/log-callauto-togglescontactFlag=trueafter 2 consecutive missed outreach calls and auto-clears on first 'reached' β both side-effects now audit with detail naming the reason (reason=outreach-reachedorreason=auto-2-misses) so a reviewer can distinguish manual vs system-triggered toggles. The call-log itself is the WorkflowEvent row already; this audit covers the Patient.contactFlag column write specifically. Reuses existing UPDATE_PATIENT action. tsc clean.
v2.74.122026-05-07ProductionFixed
- π‘οΈ **SMS STOP/START webhooks β missing audit on every consent toggle (TCPA proof gap).** Both
/api/webhooks/ringcentral/smsand/api/webhooks/twilioflipPatient.smsConsentwhen a patient texts STOP / START / UNSUBSCRIBE / etc. Without an audit row, a patient claiming 'I texted STOP but kept getting messages' has no proof on file. CAN-SPAM/TCPA risk same as the v2.74.11 email-unsubscribe fix. Both routes now: (a) findMany matched patients via the v2.74.6 phoneOrWhere helper, (b) run the existing updateMany, (c) write aPATIENT_SELF_UPDATEaudit row per matched patient with detailkind=sms-stop|sms-start source=ringcentral|twilio. Source label distinguishes which carrier handled the inbound β both surfaces are still in production (RC for healthcare-BAA path, Twilio still active during the cutover window). 4 SMS consent-toggle paths now audited (RC STOP, RC START, Twilio STOP, Twilio START). tsc clean.
v2.74.112026-05-07ProductionFixed
- π‘οΈ **
/api/unsubscribeβ missing audit on every email-unsubscribe.** CAN-SPAM + TCPA require honoring opt-outs; if a patient ever disputes 'I unsubscribed but you kept emailing me' the audit row is the proof the toggle happened. Previously: GET (clicked email link) and POST (RFC 8058 one-click from Gmail / Apple Mail spam-flag) silently flippedPatient.emailUnsubscribed = truewith zero audit trail. Fix: helper now looks up matched patient rows, runs the existingupdateMany, and writes aPATIENT_SELF_UPDATEaudit row per matched patient with detailkind=email-unsubscribe via=link|one-clickso reviewers can distinguish manual click vs spam-flag-driven auto-unsubscribe. ReusingPATIENT_SELF_UPDATE(instead of a new action) keeps the audit-log dropdown short β these are still patient-initiated profile changes. tsc clean.
v2.74.102026-05-07ProductionFixed
- π‘οΈ **Admin + provider password ops β 4 missing audit entries (mirror of v2.74.9 patient side).** New
ADMIN_PASSWORD_CHANGEandPROVIDER_PASSWORD_CHANGEaudit actions cover the four routes:forgot-password(reset link requested, kind=forgot-requested) andreset-password(bcrypt hash actually overwritten via token, kind=reset) for both admin + provider surfaces. Auditing the forgot-request itself is the more interesting signal: an attacker who knows an admin's email can spam the forgot-password endpoint and the user wouldn't see anything (it returns a silent success to prevent enumeration) β but a reviewer scanning /admin/audit-log can spot an unusual reset-link request from an unfamiliar IP. All 4 routes already had constant-time email behavior + rate-limits; this commit just adds the audit trail. tsc clean.
v2.74.92026-05-07ProductionFixed
- π‘οΈ **Patient password ops + cert download β 4 missing audit entries.** (1) New
PATIENT_PASSWORD_CHANGEaction covers the three password write paths:/api/patient/auth/set-password(first-time set, detail=kind=set),/api/patient/auth/reset-password(forgot-password flow, kind=reset),/api/patient/auth/change-password(logged-in change, kind=change). Each writes a row with resourceId=patientId after the bcrypt hash commits. Security-critical β any of these can be the start of an account takeover; without audit, 'when did this patient's password change' was unanswerable. (2)/api/patient/cert/[id]β patient downloads their own cert PDF. Reuses the existingDOWNLOAD_CERTaudit action (already used by admin-side cert downloads) with detailsource=patient-portalso admin vs self-service is distinguishable. /admin/audit-log now shows a unified view of who pulled which cert when. tsc clean.
v2.74.82026-05-07ProductionFixed
- π‘οΈ **Provider self-service routes β missing audit on PHI-affecting writes.** Two routes silently mutating provider records: (1)
/api/provider/profileβ providers self-update theirname / title / NPI / doxyMeUrl / email / photofrom their portal. doxyMeUrl drives every telehealth visit; photoUrl shows on the public providers page. (2)/api/provider/signatureβ uploads the signature image stamped onto **every cert PDF** generated for this provider going forward. Both routes wrote toProviderwithout leaving an audit row. Critical for HIPAA forensic-trail: 'did the provider actually update their signature on' was unanswerable. Fix: new PROVIDER_SELF_UPDATEaudit action (added to AuditAction union + audit-log dropdown labels + indigo per-row pill). Profile route writes detailfields=doxyMeUrl,email(whichever changed); signature route writeskind=signature. Reviewer can now scan /admin/audit-log for provider profile changes alongside other login/PHI events. tsc clean.
v2.74.72026-05-07ProductionFixed
- π‘οΈ **Patient + Provider login β missing audit + provider timing-enum.** Closes the rest of the login-audit story (v2.74.6 fixed dispensary; admin already had it). New
PATIENT_LOGINandPROVIDER_LOGINaudit actions added to the union + audit-log dropdown labels + blue per-row pills. Patient login (/api/patient/auth/login) writes a row with resourceId=patientId on every successful sign-in (PHI account access). Provider login (/api/provider/auth/login) does the same with resourceId=providerId β PHI access across many patients, even more important to audit. Provider route also had the **same timing-enum bug as dispensary** (find-miss β fast 401 / find-hit β slow 401 with ~100ms bcrypt). Patient route already used the constant-time DUMMY_HASH pattern; ported the same fix to provider. All 4 login surfaces now: (a) audit on success withaction, (b) constant-time bcrypt regardless of email validity, (c) rate-limited by IP. tsc clean._LOGIN
v2.74.62026-05-07ProductionFixed
- π‘οΈ **Dispensary login β timing-based user enumeration + missing audit.** Two HIPAA-shaped gaps in
/api/dispensary/auth/login: (1)findUniquemiss returned 401 fast (no bcrypt call); valid email + wrong password returned 401 slow (~100ms bcrypt). Attacker times responses β enumerates the dispensary roster. Fix: always runbcrypt.compareagainst either the real hash or aDUMMY_HASHconstant β same pattern as/api/patient/auth/login. Timing is now indistinguishable between 'no such email' and 'wrong password'. (2) Successful logins weren't audited, while admin login writesADMIN_LOGINfor HIPAA forensic-trail. NewDISPENSARY_LOGINaudit action (added to AuditAction union + audit-log dropdown labels + per-row blue pill matching ADMIN_LOGIN) writes a row withdetail = 'so a reviewer can answer 'which dispensaries logged in last week' from /admin/audit-log without parsing Vercel logs. tsc clean.Β· '
v2.74.52026-05-07ProductionFixed
- π‘οΈ **
/api/intake/[token]/documentsPOST β per-token rate-limit (last public-token surface).** Heaviest of the 6 public token-keyed POSTs: each request does file parsing + Vercel Blob upload (paid storage) + DB row write. Without rate-limit, a single leaked URL could be hammered to drain the Blob quota / inflate the storage bill. Same 5/5min cap as cancel/reschedule (heavier per-request work justifies tighter cap than the 10/5min on confirm/checkin/previsit). With this commit the 6 public token-keyed POST surfaces (checkin, previsit, confirm, cancel, reschedule, intake/documents) all enforce uniform per-token rate limits β single-token-leak abuse class is closed across the public surface.
v2.74.42026-05-07ProductionFixed
- π‘οΈ **
/api/appointments/{confirm,cancel,reschedule}β per-token rate-limit sweep.** Three more public token-keyed POSTs missing rate limits, completing the v2.74.2/.3 defense-in-depth pattern. (1) **confirm** β status flip + DB read; 10 / 5min same as checkin. (2) **cancel** β heaviest of the three: triggers Stripe refund + FHIR cancel API call + waitlist notify in addition to status flip. Tighter cap of 5 / 5min since each abuse cycle costs more (PCI-relevant Stripe surface + outbound FHIR API quota). (3) **reschedule** β slot transaction + SMS + email per request. Same 5 / 5min as cancel for the same reason. Token-keyed (not IP) because legit patients on cellular roam between IPs. Brute-force across UUIDv4 tokens still infeasible; this guards against single-token abuse from leaked URLs. tsc clean.
v2.74.32026-05-07ProductionFixed
- π‘οΈ **
/api/previsit/[token]β same per-token rate-limit as v2.74.2 checkin route.** v2.74.2 noted previsit was 'lower priority' because writes are idempotent upserts (no status corruption risk like the checkin status flip). On reflection still worth ratelimiting: each POST writes up to ~5500 chars of free-text fields across 4 columns, plus a findUnique + upsert (2 DB ops per request). A hostile actor with a leaked URL can spam the route to chew DB time without consequence. Same shape: 10 / 5min keyed on token, returns 429 with 'Too many attempts'. Brute-force across UUIDv4 tokens still infeasible; this guards against single-token abuse. tsc clean.
v2.74.22026-05-07ProductionFixed
- π‘οΈ **
/api/checkin/[token]β added per-token rate-limit (defense in depth).** The patient self-check-in route was a public POST that mutates appointment status (SCHEDULEDβCONFIRMED) gated only by the cancelToken UUID. Brute-force across tokens is infeasible (122-bit random) but a single leaked URL β shoulder-surfed from a patient's phone, indexed in browser history on a shared device, leaked via a screenshot β could be hammered without limit. Now: 10 requests per 5 minutes per token. Legit patients check in once so the limit is generous; a hostile actor abusing a single leaked token gets locked out fast. Token-keyed (not IP-keyed) because legit patients on cellular roam between IPs mid-session.previsit/[token]is a similar shape but its writes are idempotent inserts that don't flip status β lower priority. tsc clean.
v2.74.12026-05-07ProductionChanged
- β»οΈ **
phoneOrWhereβ drop duplicate clauses for 7-9 digit inputs.** Helper was always pushing both{ phone: { contains: digits } }AND{ phone: { contains: digits.slice(-10) } }. For a 10-digit input that's correct (e.g. raw"+12065550100"produces digits"12065550100"and last10"2065550100"β different strings, both useful as substring matches). For a short input like 9 digits, slice(-10) returns the full 9-char digits string β making the second clause a duplicate of the first. Postgres OR with duplicate predicates evaluates both separately (wasteful planning + sequential scan branches). Tightened: only push the last-10 clause when it actually differs from the full digits string. Behavior identical for the production path (10+ digit RC inbound); cleaner query plan for shorter inputs. tsc clean.
v2.74.02026-05-07ProductionFixed
- π‘οΈ **Patient password-reset β close timing side-channel + match admin route pattern.** The patient reset-password route did
findFirst({ where: { passwordResetToken } })then post-checkedpatient.passwordResetExpiry < new Date()in JS. Two issues: (1) the post-find branch produced detectably-different timing between 'token doesn't exist' (fast 400) and 'token exists but expired' (DB roundtrip + branch + 400), letting an attacker enumerate valid tokens. (2) drift from the parallel admin reset-password route (/api/admin/reset-password) which already has the cleanerpasswordResetExpiry: { gt: new Date() }clause inside the where. Both surfaces should behave identically; they do now. Same single-query response time regardless of token validity. tsc clean.
v2.73.992026-05-07ProductionFixed
- π **Two email-case bugs surfaced by adjacency to the phone-format sweep.** Same bug class β match against stored value with one normalization, but values are stored in another. (1) **CSV import** β
findUnique({ where: { email: row.email } })was using the raw casing from the CSV cell. Patient records are stored lowercase by every other write path (public booking, admin manual booking, admin user create, etc.) β but import was missing the.toLowerCase(). Result: a row like 'John@Example.com' missed an existing 'john@example.com' patient on findUnique, fell into thecreatebranch, and either hit a P2002 unique violation + got skipped silently OR (worst case) created a duplicate if the existing row's email differed only in casing. Fix: normalize once intonormalizedEmailand use everywhere (findUnique + update where + create data). (2) **Waitlist signup** βfindFirst({ where: { email: body.email, ... } })had the same gap. Patient signing up as 'John@Example.com' then again as 'john@example.com' slipped past the dedup, created two rows, the cron then notified Doug twice + emailed the patient twice on slot-open. Fix: normalize at the route boundary, use everywhere. tsc clean.
v2.73.982026-05-07ProductionFixed
- π‘οΈ **
phoneOrWherenow fails closed instead of returning{}(match-all).** Real defensive concern caught while re-reading v2.73.96/.97: the helper returned an empty object when input had < 7 digits, which Prisma treats as match-all. Today's callers all guard withif (fromDigits)first, so safe β but a future caller forgetting that guard wouldfindFirst({ where: {} })which returns the FIRST patient in the DB by default ordering, **falsely linking inbound messages to the wrong patient**. Catastrophic HIPAA/data-quality breakage waiting to happen. Fix: return{ id: '__phone_nomatch__' }(a guaranteed-no-match clause β no real CUID matches that) instead of{}for the short-input branch. Existing call sites continue working unchanged (their guards prevent ever reaching the no-match branch). New callers can drop the helper in without first reading the safety contract; the helper is now safe-by-default. Header doc rewritten to call out the fail-closed behavior + register the v2.73.97 sites in the call-site list. tsc clean.
v2.73.972026-05-07ProductionFixed
- π **Five more digit-vs-formatted phone match sites β full sweep.** v2.73.95 + v2.73.96 fixed the patient-listing search + RC webhooks; this commit closes the remaining 5 sites surfaced by
grep phone.*contains: (1)/admin/appointmentslisting search β patient sub-filter wired throughphoneSearchClauses. (2)/api/admin/patients/searchβ typeahead search API used by patient-pickers in admin booking flows. Same fix. (3)/api/admin/messages/sendβ click-to-text best-effort patient match for lead-style sends; switched tophoneOrWhere. Without this the v2.73.92 backfill audit also lied here (orphan was created at send-time, threaded later by the helper instead of at-send). (4)/api/admin/appointments/exportβ same patient sub-filter as the listing. (5)/api/webhooks/twilioβ legacy Twilio inbound STOP/START handlers had the same TCPA-risk bug as v2.73.96's RC version (patient texts STOP, formatted phone in DB, updateMany matches zero, opt-out silently doesn't take). All 5 wired into the sharedphone-search.tshelpers. The phone-format mismatch class is now closed across the codebase. tsc clean.
v2.73.962026-05-07ProductionFixed
- π **Webhook auto-link + STOP/START handlers had the same digit-vs-formatted phone bug.** Critical: while v2.73.95 fixed the search bug, the same
phone: { contains: fromDigits }pattern lived in BOTH RC webhook handlers β/api/webhooks/ringcentral/sms(auto-link + STOP + START) and/api/webhooks/ringcentral/calls(auto-link). Result: every inbound message from a real patient with a formatted phone arrived aspatientId=null, AND a TCPA/HIPAA risk: a patient texting STOP would haveupdateManymatch zero records and silently NOT toggle theirsmsConsentβ they'd keep getting SMS reminders forever despite opting out. The v2.73.92 backfill audit count was inflated as a side effect (the backfill was doing the work the webhook auto-link should have done). Fix: extractedphoneOrWhere(rawPhone)helper tosrc/lib/phone-search.tsthat ORs digits-only / last-10 /(NNN) NNN-NNNN/NNN-NNN-NNNN/NNN.NNN.NNNNclauses so any stored format matches. Wired into the SMS webhook (3 sites: auto-link + STOP + START) + calls webhook (1 site). Real prod regression β every formatted-phone patient was orphaned at receipt + invisible to STOP. Still bounded: returns{}for <7 digits so a junk inbound fails safely. tsc clean.
v2.73.952026-05-07ProductionFixed
- π **Patient search by digit-only phone silently missed every patient.** Real bug surfaced by today's own work: v2.73.86's unmatched-caller deeplink emits
/admin/patients?q=2065550100(raw last-10-digits), butPatient.phoneis stored formatted as(206) 555-0100βphone: { contains: '2065550100' }does substring match against the formatted string and never finds the digits because they're interspersed with(), spaces, hyphens. Result: the entire reconciliation flow Doug clicks through silently returned zero results, looking like 'no matching patient found' when the patient was sitting right there with formatted-phone storage. Fix: newphoneSearchClauses(q)helper insrc/lib/phone-search.tsβ when q is all digits length 10β11, OR-incontainsclauses for the three formats actually seen in the wild:(NNN) NNN-NNNN,NNN-NNN-NNNN,NNN.NNN.NNNN. Always also includes the literalcontains: qclause so users who type the formatting themselves still match. Wired into both the listing query AND the export route's query so visible filtered list and exported CSV agree. 7-digit (no area code) queries skip expansion β too ambiguous, would false-positive on every patient with a phone. tsc clean.
v2.73.942026-05-07ProductionAdded
- π **EOD email β orphan-links count in header subline.** Mirrors the v2.73.75 calls + recordings additions; closes the daily-cadence visibility on v2.73.92's
MESSAGE_BACKFILL_LINKEDaudit. NewPromise.allfetches today's count from the audit log; renders inline in the header subline asΒ· 4 orphan linksonly when count > 0 (silent on a clean day, same conditional pattern as the other call-activity fragments). Header now:. Doug's EOD reflects the entire phone + reconciliation pipeline. tsc clean.staff Β· actions Β· voicemails pending Β· β β calls Β· recordings played Β· orphan links
v2.73.932026-05-07ProductionAdded
- π **Migration cockpit + audit-log filter β 'Orphan links' surface for v2.73.92's new audit signal.** Two surfaces: (1) /admin/migration audit-channel grid gains a 5th tile counting MESSAGE_BACKFILL_LINKED audit rows in the last 7d. Grid widened from
grid-cols-2 sm:grid-cols-4βgrid-cols-2 sm:grid-cols-3 lg:grid-cols-5so the 5 tiles wrap responsively. Tone=ok (positive event). (2) /admin/audit-log filter pill row gains an 'Orphan links' pill (between 'All exports' and 'RC failures') β one click for reviewers to see only backfill events. Both deeplink to the same?action=MESSAGE_BACKFILL_LINKEDfilter. Closes the visibility loop on yesterday's backfill audit work β Doug + reviewers can see how often orphan messages get auto-threaded to patients without diving into the raw audit table. tsc clean.
v2.73.922026-05-07ProductionAdded
- π¨ **
MESSAGE_BACKFILL_LINKEDaudit action β every orphan-message reconciliation now visible.** v2.73.87 β v2.73.91 wired the backfill helper across all 5 patient write surfaces but it ran silently β no audit row when orphans actually got linked. Now: helper capturesresult.countfrom theupdateManyand writes aMESSAGE_BACKFILL_LINKEDaudit row when count > 0 (skips zero-row no-ops to avoid noise). Detail field carrieslinked=N source=where source is one ofadmin-patch / import-create / import-update / public-booking / admin-booking / patient-portal / other. Each call site updated to pass its surface label as the third argument. New action added to AuditAction union + audit-log dropdown labels ("Linked orphan messages") + emerald-toned per-row pill (positive event β orphan got reconciled). Reviewer asking 'when did this patient's pre-booking SMS get attributed' can now answer it from /admin/audit-log without a Vercel-log dive. tsc clean.
v2.73.912026-05-07ProductionAdded
- π¨ **Patient portal self-update β 5th write surface wired to backfillOrphanMessages.** v2.73.87/.88/.90 covered admin + import + booking write paths. The remaining gap was
PATCH /api/patient/profileβ the portal endpoint where patients update their own phone / address / contact preferences. Now: whendata.phonewas set in the request body, fire-and-forgetvoid backfillOrphanMessages(session.patientId, data.phone)after the audit log. Real workflow: patient texts the clinic from a new number ('hi this is my new cell'), then updates their phone in the portal β the orphan inbound auto-threads to their record without staff intervention. Helper'supdateManyonly matchespatientId=nullrows, so calling it when the phone hasn't actually changed is a no-op match (no extra cost). Helper's call-site registry header bumped to 5 surfaces. tsc clean.
v2.73.902026-05-07ProductionAdded
- π¨ **Booking flows wired to backfillOrphanMessages β public + admin-manual.** v2.73.87/.88/.89 covered admin patient PATCH + CSV import paths, but the booking write paths (
/api/appointmentsfor patient self-book,/api/admin/appointments/manualfor staff-created) also upsert patients and were missing the orphan-reconciliation. Real value: a patient who texts the clinic asking 'do you accept Aetna?' BEFORE they book β that SMS lands aspatientId=null(no record yet). When they later book, today's call adds: 'fire-and-forget backfill' after the booking transaction commits, sweeping any inbound from their number into their newly-created patient record. Both paths usevoid backfillOrphanMessages(...)so the booking response stays fast (helper has internal.catch()). Manual booking gates onbody.phonebeing provided (patientId-only flow doesn't touch the patient row, so backfill not needed). Helper's call-site registry header bumped to name both new paths so future agents see all 4 write surfaces in one place. tsc clean.
v2.73.892026-05-07ProductionChanged
- β»οΈ **Extracted
backfillOrphanMessagestosrc/lib/patient-message-backfill.ts.** v2.73.87 introduced the inline backfill in PATCH /api/admin/patients; v2.73.88 mirrored it as a local helper in /api/admin/import/patients. Two-write-path duplication = drift risk (the import version was slightly more robust β handled null/undefined phone β while the PATCH version had subtler 10-digit validation). Single source of truth now lives inlib/. Both routes import + call. Header comment names every call site so future agents picking up phone-related code see the registry of write paths needing the same backfill. Behavior unchanged; just consolidating. tsc clean.
v2.73.882026-05-07ProductionAdded
- π¨ **CSV import β orphan PatientMessage backfill mirrors the v2.73.87 PATCH route.** Bulk imports were the other write site that needed phone-driven message reconciliation. Both paths now call a small
backfillOrphanMessages(patientId, phone)helper local to the route: (1) **Create path**: any new patient with a phone number triggers the backfill β by definition no prior phone existed, so any pre-existing inbound from that number was orphaned. (2) **Update path**: only fires when the imported phone differs from the existing flat field (phone !== existing.phone) β most imports re-import unchanged phones; no point firing updateMany 5000 times. Same defensive.catch()with console.error pattern so a per-row backfill failure doesn't break the import. Real value: doing a Salesforce migration import then opens up a backlog of orphan inbound messages auto-threading to their patients without manual reconciliation. tsc clean.
v2.73.872026-05-07ProductionAdded
- π¨ **PATCH /api/admin/patients β backfill orphan PatientMessage rows when phone changes.** Closes the v2.73.86 unmatched-caller loop. Previously: the auto-link in
/api/webhooks/ringcentral/sms+/api/webhooks/ringcentral/callsonly runs at message-receipt; if the patient's phone was wrong / missing / formatted differently at that moment, the row sticks withpatientId=nullforever even after the patient's phone is later corrected. Now: when admin updates a patient's phone via the patient form, anyPatientMessagerows withpatientId=nullwhosefromAddrcontains the last 10 digits of the new number get linked back to the patient in a singleupdateMany. Triggers only when the phone field actually changed (body.phone !== prior.phone), and only when the new value normalizes to 10 digits (skips short/junk inputs). Defensive β wrapped in.catch()with console.error so a backfill failure doesn't surface a 500 to the caller; the parent patient.update commit is preserved either way. Real workflow value: from /admin/messages β unmatched caller deeplink β /admin/patients?q=β click matching patient β edit phone (or just save the form to trigger the linkage on already-correct numbers) β orphan messages auto-thread to the patient. tsc clean.
v2.73.862026-05-07ProductionAdded
- π¨ **/admin/messages β unmatched-caller rows now deeplink to patient search.** Previously: when an inbound SMS / call came in from a phone number that didn't match any Patient row, the conversation row in the global inbox rendered with
href=null(not clickable). Staff had to hand-copy the number, switch to /admin/patients, paste, search. Real friction. Now: row deeplinks to/admin/patients?q=so one click lands the staff on the patient listing pre-filtered by the number β they can either spot an existing patient whose phone formatting differed (very common β '(206) 555-0100' vs '+12065550100' vs '2065550100') and reconcile the linkage manually, or confirm no match and use the existing 'New patient' button on that page. Email-only inbound (from-address has no digits) still falls through withhref=nullsince search-by-email is a different workflow. Tight 12-line addition; no schema or API change. tsc clean.
v2.73.852026-05-07ProductionAdded
- π **/admin/audit-log β
?staff=filter + active-filter banner.** v2.73.84 added the per-staff Today column on /admin/users with a link to the audit log, but the audit-log page had nostaffURL param so the link silently filtered to nothing useful. Closing the loop: new?staff=param addsstaffUserId: filterto the where clause; active filter renders a blue banner above the form (Filtering to staff: Mariane Β· Clear staff filter), with the staff name resolved via single AdminUser lookup (falls back to CUID prefix if id doesn't resolve, e.g. deactivated). All four URL builders (action pills / date pills / form hidden input / pagination) now preserve the staff param so toggling action/date with a staff filter active stays in scope. Export route also picks up?staff=so the CSV download always matches the visible filtered set + the EXPORT_AUDIT_LOG self-audit detail recordsstaff=so a reviewer can audit who-exported-whose-trail. /admin/users 'Today' column link now correctly emits?staff=so clicking Mariane's row scopes the audit-log to her actions today. tsc clean.&from=YYYY-MM-DD&to=YYYY-MM-DD
v2.73.842026-05-07ProductionAdded
- π€ **/admin/users β per-staff 'Today' activity column.** Real HIPAA-grade insider-threat signal: shows each staff member's audit-log activity count for today + how many of those were PHI views (VIEW_PATIENT / VIEW_APPOINTMENT / LISTEN_CALL_RECORDING). Two batched
auditLog.groupByqueries on the API side (one for total today, one for PHI subset) β no N-per-user round trips. Empty user list short-circuits both. Shows 'No activity' (faded) when zero, otherwise the count (e.g. '47 actions Β· 12 PHI views' with the PHI line in amber). Whole cell links to the audit log filtered to today (Today range pill from v2.73.82). Reviewer scanning the staff list now sees who's been busy + who's been viewing the most patient records, all without leaving the page. Hidden on smaller breakpoints (hidden lg:table-cell) so the listing stays scannable on narrower windows. tsc clean.
v2.73.832026-05-07ProductionFixed
- π **/admin/audit-log table β missing Staff column.** Real HIPAA-grade gap: the page rendered Time / Action / Resource / IP but never showed who did the thing. v2.73.76 added Staff Name + Staff ID columns to the CSV export precisely because exporting without attribution defeats the purpose β but the in-page table itself was still missing them. New 'Staff' column inserted between Time and Action: renders
staffUserNamewhen present, falls back to first-8-chars ofstaffUserId(CUID prefix) with full id in tooltip, falls back to italic 'system' when both are null (cron / unattributed). Reviewer scanning the page can now answer 'who did this' without exporting first. tsc clean.
v2.73.822026-05-07ProductionFixed
- π **/admin/audit-log filter pills β preserve active date range across action toggles.** v2.73.58 wired action pills as
href={"/admin/audit-log?action=X"}which silently dropped any activefrom/todate filter when staff clicked a different action. Reviewer narrowing to last-7-days then toggling between PHI views and Recording playback would lose the date scope each click. Fix: pills now rebuild the URL viaURLSearchParamscarrying throughfrom+towhen set. Page param dropped (always reset to page 0 when changing filter β the rows below are now a different set).
Added
- ποΈ **/admin/audit-log β Today / Last 7 days quick-range pills.** New mini-row below the action pills: 'Any date' / 'Today' / 'Last 7 days'. Toggles the date range while preserving the active action filter (action pills preserve the date range; date pills preserve the action β they compose). Most reviewer queries are same-day-scoped ("who exported on Friday?") or last-week ("any RC failures this week?"); one click is much faster than typing two dates. Active range pill renders inverted dark β a slightly different palette from the action pills (slate vs emerald) so reviewers see both filter axes are independent. tsc clean.
v2.73.812026-05-07ProductionFixed
- π **/admin/audit-log β synthetic
exportsfilter now survives form submit.** v2.73.79 added the 'All exports' pill (?action=exports) but the form-based Action dropdown only had options for individual actions. When staff loaded?action=exportsthen submitted the date filter, the dropdown's defaultValue silently fell back to the empty string (no matching option), so the filter URL came out as?action=and the synthetic was lost β the row set jumped from 'all 4 EXPORT_* actions' to 'all actions'. Confusing reviewer experience. Fix: add a syntheticentry to the dropdown so the defaultValue matches and the form roundtrip preserves the filter. The page-level + export-route where clauses already special-case the string. tsc clean.
v2.73.802026-05-07ProductionAdded
- π **/admin/migration β 'Exports today' tile in the audit-channel grid.** Mirrors the v2.73.78 audit-log header pill on the cockpit. Counts today's CSV exports across all four self-audited surfaces (patients / appointments / accounting / audit-log) and renders as a 4th tile in the existing 7d activity grid. Tone=warn when > 0 so a same-day data-egress event lights up the cockpit. Deeplinks to
?action=exports(the synthetic union pill from v2.73.79). Grid widened fromgrid-cols-3βgrid-cols-2 sm:grid-cols-4so the tile fits responsively. Migration cockpit is now the one-stop ops dashboard for IssuingDoctorHistory state + RC pipeline + recording playback + same-day data egress. tsc clean.
v2.73.792026-05-07ProductionAdded
- π **/admin/audit-log β synthetic 'All exports' pill.** The existing dropdown only filters by exact action match, so seeing the entire data-egress story required four separate filter clicks (EXPORT_PATIENTS / EXPORT_APPOINTMENTS / EXPORT_ACCOUNTING / EXPORT_AUDIT_LOG). New pseudo-value
?action=exportsshort-circuits the page-level + export-route where clauses to useaction: { in: [...4 EXPORT_*...] }. New 'All exports' filter pill renders in the same row as the existing pills (between Bulk sends + RC failures). The 'Exports today' stat in the header now points at this pill instead of just EXPORT_PATIENTS, so the count + drill-down are now the same set. Same union duplicated in the export route so clicking 'Export CSV' under the All-exports view downloads the matching rows (otherwise zero β silently dumping nothing would be a confusing reviewer experience). tsc clean.
v2.73.782026-05-07ProductionAdded
- π **/admin/audit-log β 'Exports today' stat next to the Export CSV button.** Counts EXPORT_PATIENTS + EXPORT_APPOINTMENTS + EXPORT_ACCOUNTING + EXPORT_AUDIT_LOG audit rows where createdAt >= midnight today (PT). Renders as a small amber pill (
3 exports today) deeplinked to the EXPORT_PATIENTS filter β one click to see who pulled what. Hides on a clean day (no exports). HIPAA at-a-glance: a reviewer landing on the audit-log page sees same-day data-egress activity in the header without filtering. Works because v2.73.76 + v2.73.77 closed the self-audit gaps on every CSV download surface, so the count is now complete + trustworthy. tsc clean.
v2.73.772026-05-07ProductionFixed
- π **/api/admin/reports/export β self-audit gap closed for both patient + revenue paths.** Two HIPAA-shaped misses in the reports-CSV download routes: (1)
?type=patientsexported full patient PHI but didn't write an audit row β the parallel/api/admin/patients/exportwas already audited (v2.73.69) but this 'reports' shortcut wasn't. Now writesEXPORT_PATIENTSwith row count + path-suffix detail so a reviewer can distinguish the two surfaces in the trail. (2)?type=monthly(default) exports admin-only revenue data β not PHI but admin-financial. Now writesEXPORT_APPOINTMENTSwith the row count + path-suffix detail, mirroring the existingEXPORT_APPOINTMENTSaction. Also: route signature upgraded fromRequestβNextRequestsoipFromRequest()works (was using the older Next.js handler shape). tsc clean.
v2.73.762026-05-07ProductionFixed
- π **/admin/audit-log CSV export β three HIPAA-shaped gaps closed.** (1) **Staff attribution missing**: the previous export rendered Timestamp / Action / Resource ID / Detail / IP only β no Staff Name or Staff ID columns. For a SOC2 reviewer asking 'who accessed this patient's record on this date,' attribution is the entire point of the export. Added 'Staff Name' + 'Staff ID' columns reading from
staffUserName+staffUserId. (2) **Naive CSV escaping silently corrupted data**: the previous version replaced commas in the detail field with semicolons ((e.detail ?? '').replace(/,/g, ';')) β that mutates audit-log content during export, which a HIPAA reviewer would correctly flag. Replaced with proper RFC 4180 quoting via acsvCell()helper that wraps in"..."and doubles internal"chars. Commas, newlines, and quotes inside any cell are now safe. (3) **No self-audit**: exporting the audit log is itself a HIPAA-sensitive event but wasn't being audited. NewEXPORT_AUDIT_LOGaudit action (added to the AuditAction union + ACTION_LABELS + ACTION_COLORS amber palette so it shows up in the dropdown / per-row pills) writes a row capturing the row count + active filter shape, so 'who exported the trail when with what filters' is itself in the trail. tsc clean.
v2.73.752026-05-07ProductionAdded
- π **EOD email β call activity in the header subline.** The 6pm staff-productivity recap already showed voicemail-pending count; now also shows today's call volume (
5β 12β callsfor inbound/outbound) and recording-playback events (3 recordings played). Each fragment renders only when count > 0 so a quiet day stays clean. Three newPromise.allcount queries off the audit log + PatientMessage table β same shape as the v2.73.60 migration cockpit panels but on a single-day window. Header subline is now:. Doug's daily ops loop now reflects the entire phone pipeline at end of day. tsc clean.staff Β· actions Β· voicemails pending Β· β β calls Β· recordings played
v2.73.742026-05-07ProductionAdded
- π© **Daily briefing email β 'Today's flagged patients' alert.** Mirrors v2.73.73's weekly cleanup queues but for the morning ops cadence. Single line above the today's-appointments table: 'π© Today's flagged patients: 2 dormant Β· 1 hard-to-reach Β· 0 cert-expired Β· Open today's queue β'. Each fragment only renders when its count > 0; banner hides entirely on a clean day. Same dormancy semantics as /admin/today (
COMPLETED, startsAt < todayStart); contactFlag + certExpired read directly off the included patient. Three new optionaldailyBriefingEmailparams; older callers unaffected. tsc clean.
v2.73.732026-05-07ProductionAdded
- π¨ **Weekly digest email β 'Cleanup queues' section.** Today's dormancy + coverage signals now ride into the Monday digest cron, so Doug sees the cleanup queues without opening the app. New
weeklyDigestEmail({ dormantCount, missingDoctorCount, noRecordsCount })optional params; the section only renders when at least one is provided. Three rows render: 'Dormant patients (12+ mo no visit)' (warn-toned color when > 50), 'No issuing doctor on record' (warn-toned when > 0), 'No medical records uploaded' (neutral). Cron fetches via three additionalpatient.countqueries in the existingPromise.allβ same Prisma semantics as the listing chips so digest counts match the in-app filter counts. Older non-GW callers passing the existing fields without these new ones unaffected (all three are optional). tsc clean.
v2.73.722026-05-07ProductionAdded
- π© **/admin/today β 'Hard to reach' + 'Cert expired' badges.** Two more pre-visit signals next to v2.73.71's 'Returning after gap' pill: (1) red 'Hard to reach' badge with Flag icon when
Patient.contactFlagis set (matches the existing /admin/patients listing flag β same source, same red palette so staff recognize it). (2) Orange 'Cert expired' badge with AlertTriangle icon whenPatient.certExpiryDateis in the past relative to the day's start (cert expired patients getting a visit *today* are usually getting it renewed, but knowing means staff can confirm renewal in the visit). Both data points piped through/api/admin/today's patient projection. tsc clean.
v2.73.712026-05-07ProductionAdded
- π **/admin/today β 'Returning after gap' badge for dormant patients on today's schedule.** When a returning patient on today's queue has no COMPLETED visit in the prior 365 days (excluding today's appt itself), the row gains a slate-toned pill alongside the existing Returning / New patient pill. Tooltip carries the last-completed-visit date so staff can prep re-engagement before the visit. Implemented via batch dormancy query in /api/admin/today (single
appointment.groupBy({ _max: startsAt, where: COMPLETED, startsAt: { lt: dayStart } })β 'startsAt < dayStart' is the key piece; without it today's visit would self-cancel the dormancy signal). Pure leads (no prior completed visit) intentionally NOT flagged here β they're 'New patient' which the existing pill already handles. tsc clean.
v2.73.702026-05-07ProductionAdded
- π **/admin/patients β 'No records' filter chip + buildToggle generalization.** Mirror of the dormancy / no-doctor pattern applied to MedicalDocument coverage. New
?noRecords=1URL param filters viamedicalDocuments: { none: {} }. Filter chip sits in the row: blue-toned, shows total when active, tooltip 'pre-launch import gap'. Export route also picks up?noRecords=1so the CSV is always the visible filtered queue. **Refactor**:buildToggle()helper generalized to a typedToggleKeyunion + iteration over afiltersrecord β adding the next chip is nowadd to ToggleKey + add to filters{}(no per-toggle copy-paste). Four chips on the listing now compose freely: Flagged Β· Dormant Β· No doctor Β· No records β Doug clicks two chips, gets the intersection, exports the CSV. tsc clean.
v2.73.692026-05-07ProductionChanged
- π€ **Patient export CSV β picks up new filters + adds dormancy / doctor / opt-out columns.** The /admin/patients listing gained
?flagged=1,?dormant=1,?missingDoctor=1filters today; the export route only honored?qand?expiryso the exported CSV diverged from the visible filtered list. Now the export reflects whatever chip is active β Doug clicks Dormant on the listing, hits Export, the CSV is the win-back queue (not all patients). Five new columns added so the exported list is actionable outside the app: Email Unsubscribed (Yes/No, paired with the existing SMS Consent column), Last Completed Visit (date), Days Since Last Visit (integer), Dormant (Yes / blank), Issuing Doctor (name from flat field). Single batchappointment.groupBy({ _max: startsAt, where: COMPLETED })adds the last-completed lookup β same pattern as v2.73.66's listing badge query so listing + export stay in sync. tsc clean.
v2.73.682026-05-07ProductionAdded
- π©Ί **/admin/patients β 'No doctor' filter chip + reports hero card now deeplinks to the cleanup queue.** Mirror of the dormancy arc applied to the doctor-coverage gap. New
?missingDoctor=1URL param filters to patients withissuingDoctor: nullβ same set as the v2.73.46 / v2.73.53 hero card. Filter chip sits next to Dormant: amber-toned, shows total when active, tooltip explains the criterion ('admin edit or CSV re-import to fill in'). The three URL toggle hrefs (flagged / dormant / missingDoctor) extracted into abuildToggle(toggleKey)helper since the per-toggle copy-and-flip pattern was duplicating. **Reports hero card upgrade**: 'No doctor on record' card on /admin/reports landing now deeplinks to/admin/patients?missingDoctor=1(the actionable queue) when count > 0; falls back to the CRM report when zero. Sub-line copy updates to 'Coverage gap β click for cleanup queue'. Result: from any signal surface (landing hero, CRM hero, listing chip), Doug is one click from the patients that need fixing. tsc clean.
v2.73.672026-05-07ProductionAdded
- π **/admin/patients β Dormant filter chip.** Completes the dormancy arc (banner v2.73.65, listing badge v2.73.66, filter v2.73.67). New
?dormant=1URL param filters the listing to patients with at least one COMPLETED visit before 12mo ago AND no COMPLETED visit since β same Prismaappointments.some + appointments.nonesemantics as the /admin/reports/crm dormant-cohort query, so listing + report agree on count. Filter chip sits next to the existing Hard-to-reach pill: slate-toned, shows total count when active, tooltip explains the criterion. URL params compose freely with the existing q / expiry / sort / flagged filters. Export-CSV link picks up the dormant filter automatically. Pure leads (zero completed visits) intentionally excluded from the dormant set. Result: Doug clicks 'Dormant', gets the win-back outreach queue. tsc clean.
v2.73.662026-05-07ProductionAdded
- π **/admin/patients listing β Dormant badge on rows with no completed visit > 365d.** v2.73.65 added the banner on patient detail; v2.73.66 surfaces the same signal on the listing so dormancy is scannable without drilling in. Single batch
db.appointment.groupBy({ _max: startsAt, where: status='COMPLETED' })query per page render β one extra round-trip, indexed on (patientId, startsAt). Map keyed by patientId; row check is a constant-timedormancyMap.get(p.id)lookup. Badge: 9px uppercase 'Dormant' pill, slate palette, sits next to the existing Flag (hard-to-reach) badge in the name cell. Tooltip carries the full last-completed-visit date + year-ago count. Pure leads (no completed visit) intentionally absent from the dormancy map β no badge β no false-positive on first-time prospects. tsc clean.
v2.73.652026-05-07ProductionAdded
- π **Patient detail β dormancy banner.** When a patient has at least one completed visit, the most recent was > 365 days ago, AND there's no upcoming appointment, render a slate-toned banner above the patient info card: 'Dormant patient β no visit in N year(s) M mo Β· Last completed visit:
'. Win-back booking CTA inline (deeplinks to /admin/appointments/new?patientId=...&type=returning). Pure leads (zero completed visits) are intentionally NOT flagged β the 12mo signal is for *returning* patients who lapsed, not first-time prospects. Slate palette deliberately differs from the cert-status banner's amber/orange/red so dormancy reads as 'attention-worthy but not urgent' (vs cert expiry which is time-sensitive). Cross-references the existing /admin/reports/crm dormant cohort panel via inline link. tsc clean.
v2.73.642026-05-07ProductionChanged
- π **
extended β children + onClickStop + title props, plus three /admin dashboard sites wired.** Component generalized to support arbitrary inner markup (icon + label, 'Call' button shape, etc.) via children prop. Also addedonClickStop(for nested clickable rows that need stopPropagation) andtitle(tooltip pass-through). Three admin dashboard root sites swapped: (1) next-up appointments row β phone-icon + number with stopPropagation so the parent row click still navigates. (2) cert-expiring patient list β phone as a block link. (3) intake-missing alert β 'Call' button-like glyph with phone tooltip. Tel-scheme normalization centralized: every PhoneDialLink emitstel:+1xxxxxxxxxxregardless of input formatting (raw 10-digit, formatted '(206) 555-0100', already-E164, etc.) β same logic the prior /admin/page.tsx had hand-rolled. Other surfaces (patient detail, listing, today) continue working unchanged. tsc clean.
v2.73.632026-05-07ProductionChanged
- π **
extended to /admin/today appointment rows.** Most-trafficked admin surface β every visible appointment row showedappt.patient.phoneas a statictel:anchor. Now uses: clicking any patient phone in the day's queue dials in-browser via the softphone widget when mounted, falls through to nativetel:otherwise. The page retained itstext-[#2d6a4f] hover:underlinestyling via the className override. Same pattern as v2.73.61 + v2.73.62 β drop-in replacement, no behavior change for environments without the softphone widget configured. tsc clean.
v2.73.622026-05-07ProductionChanged
- π **
extended to /admin/patients listing.** Component moved from the [id]-route-private folder tosrc/app/admin/_components/PhoneDialLink.tsxso any admin surface can import it. Patient listing's phone column (previously a static) now renders the same softphone-aware anchor β when the softphone widget is mounted, clicking any patient's phone in the list dials in-browser; otherwise falls through to native{p.phone}
tel:. Component className override accepts the existingtext-xs text-[#5a7a68]styling + addshover:text-[#2d6a4f]so the now-clickable phone has a visible affordance. Patient detail page import path updated to../../_components/PhoneDialLink. Followups: any other admin surface that shows a patient phone (mailing labels, today's appointments, unmatched-caller numbers in /admin/messages) can now drop in the same component.
v2.73.612026-05-07ProductionChanged
- π **Patient detail Phone field β softphone-aware click.** v2.73.36 introduced
window.rcSoftphoneDial(phone, name)for click-to-dial wiring, and v2.73.36's CommunicationPanel started using it. The patient header's Phone field still rendered a staticthough β clicking would invoke the OS-level dialer (or no-op on desktop with no tel: handler). New tiny client componentwraps the phone number: whenwindow.rcSoftphoneDialis defined (softphone widget is mounted + signed in),e.preventDefault()+ dial in-browser; otherwise the click falls through to the nativetel:handler so phones / OS-installed RingCentral apps still work. Render is identical to the prior anchor β purely a click-handler swap, layout unchanged. Now every place a patient's phone appears (header, CommunicationPanel call button, future mailings page) gets the same dial-via-softphone behavior. tsc clean.
v2.73.602026-05-07ProductionAdded
- π **/admin/migration β Audit-channel activity grid (7d).** Mirrors v2.73.59's history-activity stat across the three audit actions today's sprint introduced. New
component renders three deeplinked cards in a 3-column grid: Recording playback (LISTEN_CALL_RECORDING) Β· RC renewal failures (RC_WEBHOOK_RENEW_FAILED, tone=warn when > 0) Β· RC recreations (RC_WEBHOOK_RECREATED, tone=warn when > 2 β single recreation is normal weekly drift, repeated means the cron is missing its window). Each tile is a Link to the matching /admin/audit-log filter pill so a non-zero count is one click away from the per-row ledger. Migration cockpit is now the one-stop ops dashboard for IssuingDoctorHistory state + audit-channel signal. tsc clean.
v2.73.592026-05-07ProductionAdded
- π **/admin/migration β last-7-day activity panel.** Counts non-backfill IssuingDoctorHistory rows created in the last 7 days. Real signal post-migration-19 about whether the write paths (admin patient PATCH, CSV import) are actually being exercised β zero rows after a week means staff isn't using the patient-edit form / no imports running, so the v2.73.39 β v2.73.45 instrumentation is wired but inert. Renders only when migrationApplied is true (P2021 catch returns null otherwise; the panel hides). Activity icon turns slate-grey at zero, emerald at any positive count. Copy adapts: 0 β 'write paths wired but unexercised'; >0 β 'write paths exercising β drill into /admin/reports/crm for the ledger'. tsc clean.
v2.73.582026-05-07ProductionAdded
- π **/admin/audit-log β quick-filter pills + labels for new audit actions.** Three additions: (1)
ACTION_LABELSandACTION_COLORSextended for today's three new audit kinds βRC_WEBHOOK_RENEW_FAILED('RC webhook renewal failed', rose),RC_WEBHOOK_RECREATED('RC webhook recreated', amber),LISTEN_CALL_RECORDING('Listened to call recording', teal β same as VIEW_PATIENT since it's a read-PHI event). The existing dropdown + per-row pills now render these with human-readable labels instead of raw enum strings. (2) New quick-filter pill row above the existing form: All Β· PHI views Β· Recording playback Β· Bulk sends Β· RC failures Β· SF failures Β· Email failures Β· Imports. Each pill is a deeplink (/admin/audit-log?action=...) β one click pulls the filtered view. Active pill renders inverted (white-on-green); inactive pills are hover-highlighted. (3) Pills work alongside the existing Action dropdown β pick a pill OR pick from the dropdown OR add date filters; URL params compose freely. tsc clean.
v2.73.572026-05-07ProductionChanged
- π©Ί **Patient detail history timeline β unified source-pill visual.** v2.73.39 had inline italic '(imported)' annotation specifically for backfill rows, silent for admin/import/patient. v2.73.56 introduced the colored
for the current row. v2.73.57 unifies: every row in the prior-historyul now uses the same(emerald=admin, blue=import, violet=patient, slate=backfill). Consistent visual language across the current-row span + the timeline rows + the /admin/reports/crm Recent doctor changes ledger. Replacing italic text with a colored pill also makes the timeline scannable at a glance β admin edits stand out from CSV imports stand out from backfill noise. tsc clean.
v2.73.562026-05-07ProductionAdded
- π©Ί **Patient detail β source pill on the current Issuing doctor.** v2.73.54 surfaced tenure ('2 yr 3 mo with this patient'); v2.73.56 surfaces *how* the assignment was made. New
component renders alongside the tenure span: emerald=admin (clinic staff edit), blue=import (CSV row), violet=patient (future self-update), slate=backfill (one-time migration). 9px uppercase pill so it inlines without dominating the row. Real data-quality value: at a glance you can tell 'Dr. Smith was set by the 2024 SF import' vs 'Doug edited this last week' β important context for clinic staff reviewing a patient. Reads fromissuingDoctorHistory[0].source(the latest open row); included in the existing query select. tsc clean.
v2.73.552026-05-07ProductionAdded
- π οΈ **/admin/launch readiness β migration-19 + migration-20 status rows.** The launch cockpit's database-migrations section was tracking up through prod-migration-18; today's two new migrations (19: IssuingDoctorHistory table + backfill; 20: dual-open-row unique partial index) now render alongside. Schema introspection extended:
mig_19checksinformation_schema.tablesfor the IssuingDoctorHistory table;mig_20checkspg_indexesfor the partial-unique index nameIssuingDoctorHistory_patientId_open_unique. Both rendered with the existing severity model β applied = ready (green), not applied = blocker (red) with the printednode -eapply command. The launch page is now a one-stop migration-status surface (5 migrations tracked, 1h cached on build SHA so a fresh deploy invalidates).
v2.73.542026-05-07ProductionAdded
- π©Ί **Patient detail timeline β tenure-with-each-doctor annotation.** Doug 2026-05-07: 'rec issuing dr (sometimes they change over the years).' v2.73.39 added the date range; v2.73.54 adds the duration. New
fmtTenure(start, end)helper renders compact spans:12 d/8 mo/2 yr 3 mo/5 yr(no rem-month if zero). Three places it now appears: (1) inline on the main 'Issuing doctor' line βDr. Smith Β· 2 yr 3 mo with this patientβ surfaces only when there's an open history row (endedAt = null). (2) Each prior-historyrow gains a tenure span:2018-03-15 β 2020-06-22 Β· 2 yr 3 mo. (3) Open-ended prior rows (endedAt = null but a newer row exists β should be impossible after v2.73.45's unique partial index, but defensive) compute againstnow. Reads cleanly against Doug's framing β at a glance you can see 'this patient was with Dr. Smith for 2 years, switched to Dr. Lopez 6 months ago' without doing the date subtraction yourself. tsc clean.
v2.73.532026-05-07ProductionAdded
- π©Ί **Reports landing hero β 5th card 'No doctor on record'.** v2.73.46 surfaced this signal on /admin/reports/crm; today's grind extends it to /admin/reports landing so Doug sees the gap from the entry point. New 5th card with
UserMinusicon, count of patients withissuingDoctor: null, deeplinks to /admin/reports/crm. Tone=warn whenever > 0 ('Coverage gap β admin edit or CSV re-import' sub-line) β tone=ok ('Coverage complete') when zero. Hero grid widened fromlg:grid-cols-4βlg:grid-cols-5; mobile staysgrid-cols-2. The Promise.all gains one cheappatient.countquery for the value. Why flat-field check vs history-aware: admin/import paths keep the flat field in sync with the latest open history row, so the cheaper query produces the same set β and works pre-migration too. tsc clean.
v2.73.522026-05-07ProductionAdded
- π οΈ **AdminNav β Migration link.** v2.73.51 shipped
/admin/migrationbut left it unlinked from the nav (Doug had to type the URL). Added under the Admin section after Audit Log, using the Package icon (mirrors 'shipping container' connotation). ROLE_ALLOWED is unchanged: ADMIN gets*so the link surfaces automatically; MANAGER's allowlist doesn't include it (intentional β migration is ADMIN-only territory). Single-line nav addition.
v2.73.512026-05-07ProductionAdded
- π οΈ **/admin/migration β cockpit page for Salesforce retirement + IssuingDoctorHistory state.** Doug asked for this earlier in the sprint; deferred initially because reading the on-disk SF inventory JSON requires Blob upload (those files live on dev local-disk, not in prod). Shipped as a runtime-data-only landing β counts what's already in the GW database without requiring file reads. Three signal panels: (1) IssuingDoctorHistory section detects whether prod-migration-19.sql has been applied (catches P2021 β renders amber 'not yet applied' card with the psql one-liner; otherwise renders emerald 'applied' card with row counts split by source β backfill / admin / import). (2) Patient β doctor coverage shows
X/Y patients have an issuingDoctor on recordwith deeplink to /admin/reports/crm. (3) Salesforce inventory section explains thescripts/sf-inventory.mjsworkflow + IMPORTANT_FIELDS hand-off so Doug knows exactly what to do next. (4) Doug-side actions card surfaces today's two new gates (prod-migration-20.sql unique-index + NEXT_PUBLIC_RC_CLIENT_ID) inline. Lightweight β no file system reads, no third-party calls, just Prisma counts. tsc clean.
v2.73.502026-05-07ProductionChanged
- π€ **CRM Recent doctor changes β resolve
changedByCUID β admin name.** v2.73.47 shipped the audit-style ledger but the Changed by column showed the first-8-chars of the staff CUID (hard to interpret at a glance). Now: batch lookup againstAdminUser(only for the non-null CUIDs that appear in the visible 10 rows), display the actual name (e.g. 'Mariane Lopez') in the column. Falls back to the CUID prefix only when the row'schangedBydoesn't match a known admin (e.g. an admin who's been deactivated or a non-staff source string that snuck in). The schema doesn't FKchangedBy(it can also beimport/backfill/patient), so the batch lookup is the right pattern β no Prisma include possible. tsc clean.
v2.73.492026-05-07ProductionAdded
- π **/admin/reports/calls β RC renewal events panel.** Mirrors the v2.73.48 playback ledger pattern, applied to v2.73.35's
RC_WEBHOOK_RENEW_FAILEDandRC_WEBHOOK_RECREATEDaudit actions. Last 10 rows: When Β· Event pill (rose=Failed, amber=Recreated) Β· Detail field. Zero is the steady-state β a Failed cluster means RC inbound is dying ahead of the 7-day expiry window; a Recreated cluster means the daily cron is missing its window for some reason. Either signal here means staff sees it without a Vercel-log dive. Hides itself when zero rows. Three audit-style ledgers now live on the calls report (Top callers + Recent recording playback + RC renewal events) β the page is now a one-stop ops surface for the entire RC pipeline.
v2.73.482026-05-07ProductionAdded
- π§ **/admin/reports/calls β Recent recording playback panel.** Mirrors the v2.73.47 doctor-changes ledger pattern, applied to the v2.73.38
LISTEN_CALL_RECORDINGaudit action. Last 10 rows: Played at Β· Staff (name when present, falls back to first-8-chars of staffUserId CUID) Β· Message id (first 12 chars) Β· Detail field. Surfaces who has been listening to patient audio β real HIPAA forensic value, a SOC2 reviewer asking 'show me everyone who accessed this patient's recordings' can pull it from this surface without a Vercel-log dive. Hides itself when no playback events exist yet (so pre-RC-Healthcare-BAA environments don't show an empty section). Sits at the bottom of the calls report, between the Top Callers table and the recording-coverage footer.
v2.73.472026-05-07ProductionAdded
- π©Ί **/admin/reports/crm β Recent doctor changes panel.** Last 10 IssuingDoctorHistory rows rendered as an audit-style ledger (Patient Β· New doctor Β· Effective date Β· Source pill Β· Changed by). Backfill rows excluded so the panel surfaces only real change events. Source pills are tonally distinct: emerald for
admin(clinic staff edit), blue forimport(CSV row), violet forpatient(future self-update). Patient column deeplinks to/admin/patients/[id]for context drill-down.changedByshows the first 8 chars of the admin CUID β enough to disambiguate between admins without showing the full id. Hides itself when there are no non-backfill rows yet (so pre-edit-activity environments don't show an empty section). Defensive against P2021 (table missing) β caught at the query, returns empty, panel hides.
v2.73.462026-05-07ProductionAdded
- π©Ί **CRM hero β 'No doctor on record' coverage signal.** Now that prod-migration-19.sql is applied + IssuingDoctorHistory is populated, a real coverage gap is visible: patients with neither an open history row nor a flat
issuingDoctorfield. New 5th hero card on /admin/reports/crm shows the count + percent (or 'Coverage complete' when zero), tone=warn whenever > 0. Pre-launch hygiene signal β these are records that need an admin edit or CSV re-import to fill in. Hero grid was 4 columns, now 5; layout still wraps on sm: withgrid-cols-2 sm:grid-cols-5.
v2.73.452026-05-07ProductionChanged
- π©Ί **CRM doctor-distribution panel β history-aware read + concurrent-write hardening.** Doug applied prod-migration-19.sql in prod (the v2.73.39 gate), so
IssuingDoctorHistoryis now populated by the backfill + every admin patient PATCH + every CSV import. The /admin/reports/crm doctor-distribution panel now reads the latest open history row per patient (endedAt = null) for the **Current** count, falling back to the flatPatient.issuingDoctorfield only when history is empty (defensive for any environment where the migration hasn't run). New **Former** panel sits below: doctors who once issued for at least one patient but have since been replaced β Doug-flagged dimension ('sometimes they change over the years'). Computed by deduping per-patient across all history rows then subtracting the current set; rendered with a slate bar instead of green so the visual hierarchy is unmissable. Pre-migration the Former panel hides itself entirely (length === 0). Section title split: 'Recommending physician β current' / 'Recommending physician β former'. - π‘οΈ **prod-migration-20.sql + helper P2002 handling β defend against dual-open-row race.** Pre-commit Explore review of the history-aware read flagged: the helper closes-prior + inserts-new in one transaction *per-patient*, but concurrent admin edits to the same patient could in theory leave two open rows. The read path returns the latest open gracefully so the bug is invisible at the report level, but it's still a state-machine assertion violation. Fix at two layers: (1) **prod-migration-20.sql** β
CREATE UNIQUE INDEX ... ON IssuingDoctorHistory (patientId) WHERE endedAt IS NULL(idempotent). DB now enforces 'at most one open row per patient' as an invariant. (2) **Helper handles P2002** β the unique-violation surfaces as a clean console.warn (so a real-prod race is visible) and the function returns without re-throwing, so the parentpatient.updatePATCH still commits and the admin doesn't see a misleading 500. Lossy under double-edit (loser's doctor value is dropped) but surfaced β if the log ever fires more than rarely we ship a retry-on-conflict pass.
v2.73.442026-05-07ProductionChanged
- π **Sprint wrap-up β TODO.md refresh + Doug-actions-outstanding consolidation.** 9 versions shipped today (v2.73.35 β v2.73.43, GW phone integration + CRM reports + IssuingDoctorHistory arc) β without a wrap-up the queue of outstanding Doug-side actions was scattered across multiple sections of TODO.md. New top-of-file consolidated punch list: today's two new gates (apply prod-migration-19.sql; set NEXT_PUBLIC_RC_CLIENT_ID + register RC OAuth redirect URI) plus the carry-forward vendor blockers (RC Healthcare BAA + 10DLC; Anthropic BAA; Postmark/SES BAA; provide IMPORTANT_FIELDS column list). Also marked Stage-3 click-to-text/call UI section with the four new completions (softphone widget, inline recording playback + audit, presence dot, recording-coverage chip), Stage-5 SF retirement section as
[~]in-progress (inventory script shipped, awaiting column list), and refreshed the 'Currently In Progress' rolling-recent block with the 9-version sprint summary. Doc-only change. No code, no migration, no behavior change.
v2.73.432026-05-07ProductionAdded
- π’ **AdminNav β RC presence dot.** Tiny status indicator (1.5Γ1.5 rounded dot with ring halo) next to the user-name in the sidebar footer. Mirrors the in-app softphone's signed-in / on-call state by listening to the same postMessage events the RcSoftphone widget uses (rc-login-status-notify / rc-call-ring-notify / rc-active-call-notify / rc-call-end-notify) β same
e.origin === apps.ringcentral.comguard so a malicious frame can't spoof presence. State machine: signed-out (slate, 30% opacity) β signed-in (emerald) β ringing (amber, animate-pulse) β on-call (amber solid). Login events don't downgrade ringing/on-call back to signed-in mid-call (the call events overlay on top of presence). Env-gated on NEXT_PUBLIC_RC_CLIENT_ID β invisible in environments where the softphone isn't wired. Tooltip + aria-label carry the human-readable presence ('Softphone available' / 'Incoming call' / 'On a call' / 'Softphone signed out') for screenreaders + hover. Lets staff glance at the corner to know if they're available to take a patient call without expanding the docked widget β completes the visible-state loop on the v2.73.36 softphone work.
Fixed
- π **RcSoftphone state-machine β mid-call login-status flicker.** Pre-commit Explore review of the new presence dot caught a mirror-divergence: RcSoftphone's loginStatus handler unconditionally flipped to 'signed-out' on every
rc-login-status-notify { loggedIn: false }, including the brief refresh-token-rotation events RC fires mid-call. The new RcPresenceDot (this same version) had the right guard but RcSoftphone didn't β under a real mid-call refresh the dot stayed on-call/amber while the softphone header flashed a misleading 'Sign in' amber tag. Fix: introduceinCallRef(useRef tracking ringing+active call window, set onrc-call-ring-notify, kept throughrc-active-call-notify, cleared onrc-call-end-notify). The login-status handler now refuses to downgrade signed-in β signed-out wheninCallRef.currentis true.hasIncoming(the 'Incoming' badge UX) andinCallRef(the login-guard) are kept as separate signals βhasIncomingclears as soon as the call connects (correct UX),inCallRefonly clears when the call ends (correct guard scope). Mirror parity restored between widget + presence dot.
v2.73.422026-05-07ProductionAdded
- π§ **Global inbox β recording-coverage signal on the Calls tab.** The Calls filter pill on
/admin/messagesalready worked (v2.73.x β shows onlychannel='CALL'rows) but staff had no at-a-glance way to see whether the visible calls had recordings on file. Two additions: (1) a coverage chip beneath the filter tabs, only on the Calls tab, showingX/Y calls have recordings(with a friendlyRC recording disabled or BAA pendinghint when 0/Y). The recording switch is RC-side and gates on the Healthcare BAA β surfacing the ratio is a useful health check ('is recording even on?'). (2) Per-row π§ indicator in the call snippet (Inbound call Β· 1m 23s Β· π§ recording) so the headphone glyph reads at a glance even when scanning a mixed view. **API hardening**:/api/admin/messagespreviously didn't returnrecordingUrl(so the inbox couldn't know). Adding it raw would leak RC URLs (which still need a bearer token at the proxy layer to fetch) β instead the route now projectshasRecording: booleanserver-side and drops the URL. Inbox + per-row playback (CommunicationPanel) both go through the audited/api/admin/messages/[id]/recordingproxy as before. tsc clean.
v2.73.412026-05-07ProductionAdded
- π©Ί **CSV import β IssuingDoctorHistory.** v2.73.39 introduced the history model + admin write hook + backfill but the third write site β CSV import (
/api/admin/import/patients) β still only set the flatPatient.issuingDoctorfield. Now both create + update paths callrecordIssuingDoctorChange()withsource='import'. Create path passesprevName=nullto seed the relationship for a new patient; update path passesexisting.issuingDoctorso a row only lands when the imported doctor actually differs from the current one. **The helper now accepts an optionaleffectiveAtoverride** so imports can set the historical date (CSVs typically have apatientSincecolumn representing when the patient joined that doctor's care β the import passes that through, so the timeline reflects 'this patient started with Dr. X in 2018', not 'we imported them at 2pm today'). When closing a previous open row mid-import theendedAtmatches the new effectiveAt, avoiding awkward gaps in the per-patient timeline. Same defensive P2021 swallow as the admin path β imports succeed even before prod-migration-19.sql lands; once it runs every backfill row + every subsequent import row coexists cleanly.
v2.73.402026-05-07ProductionAdded
- π― **Reports landing β at-a-glance hero strip across all 5 tabs.** Doug 2026-05-07: 'dial in the dashboard.' Before this change /admin/reports landing showed only the revenue tab in disguise β the other 4 tabs (health / funnel / calls / crm) needed a click to surface even one signal. Now there are 4 cross-tab cards at the top, each a deeplink to its tab: (1) **Calls (7d)** β total + answer rate + inbound count; tone=warn when answer-rate < 70%. (2) **Cert renewals due (7d)** + dormant-12mo+ count; tone=warn when any cert is due. (3) **This week** β booked appts + no-show count; tone=warn when no-shows > 2. (4) **Revenue (this month)** β current month + signed delta vs last month. New
getOverviewSignals()Promise.all'd alongside the existinggetMonthlyStats / getProviderStats / getPromoStatsqueries so the page TTFB doesn't regress. Newcomponent (denser than the existing HighlightCard β 14px icon + 10px uppercase label + 20px headline + 10px sub) renders the whole card as awith hover-tonal feedback (amber on warn, slate-green on ok) and a subtleglyph that brightens on hover. Existing month-highlight grid + monthly breakdown table + provider activity + promo performance unchanged below β the hero strip is purely additive. Cross-tab signal sourcing reuses the same query logic already in calls/crm/health pages (no drift between landing + tabs).
v2.73.392026-05-07ProductionAdded
- π©Ί **IssuingDoctorHistory β per-patient timeline of recommending-physician changes.** Doug 2026-05-07: 'rec issuing dr (sometimes they change over the years).'
Patient.issuingDoctoris a flat field that loses history on every update. New append-onlyIssuingDoctorHistorymodel (id / patientId / doctorName / effectiveAt / endedAt / changedBy / source / notes) with index on (patientId, effectiveAt) and ON DELETE CASCADE on the patient FK. **Migrationprod-migration-19.sql** ships idempotent (CREATE TABLE IF NOT EXISTS, FK guard via pg_constraint check,WHERE NOT EXISTSbackfill clause). Backfill inserts one row per patient whose flatissuingDoctoris set, witheffectiveAt = patientSince ?? createdAtandsource='backfill'so reports can distinguish migration-imported from real change events. **Write hook**:recordIssuingDoctorChange()helper insrc/lib/issuing-doctor-history.tscloses the open row (endedAt = now) and inserts a new open row in a single transaction β empty new value closes only (no churn rows). Wired intoPATCH /api/admin/patientsso every admin edit that changes the doctor lands a history row. Helper is **defensive against the migration not being applied yet** β Prisma error code P2021 (table missing) is swallowed with a console warning, parent UPDATE_PATIENT path unaffected. **Read surface**: patient detail page now renders the latest doctor inline (unchanged) PLUS awithN prior Β· view historysummary that expands a vertical timeline (doctor name + effectiveAt β endedAt range, italic '(imported)' tag for backfill rows). Surfaces only when there's >1 row. CRM-report doctor distribution still reads the flat field for now (history-aware version is a follow-up β wants to optionally show 'tenure-weighted' or 'current-only' modes). Doug action when ready: run the migration in prod (psql against DATABASE_URL with prod-migration-19.sql), then every patient edit going forward writes history automatically.
v2.73.382026-05-07ProductionAdded
- π§ **Inline call-recording playback in CommunicationPanel + HIPAA audit row on play.** The
/api/admin/messages/[id]/recordingproxy already existed (fetches RC URL with bearer, streams audio bytes back) and the CommunicationPanel already showed a Listen link β but two HIPAA-shaped gaps closed today: (1) listening to a patient call is PHI access; the proxy now writes aLISTEN_CALL_RECORDINGaudit row before streaming so a forensic-trail review can answer 'who listened to this patient's calls and when'. New audit action added to the union;audit()readsx-admin-id/x-admin-namefrom headers (proxy.ts injects them after session verification) so attribution is automatic β no extra plumbing in the route. (2) Listen UX changed from(audio opens in a new tab β staff loses context) to an in-place toggle that reveals anelement below the message bubble. State (openRecording: string | null) keeps only one recording expanded at a time so audio can't double up. Aria attributes (aria-expanded,aria-controls,aria-label='Call recording playback') for screenreader support. preload='none' avoids fetching audio (and triggering the audit row) until the staff member explicitly clicks Listen.
v2.73.372026-05-07ProductionAdded
- π **Standard call reports β
/admin/reports/calls.** New tab on the reports nav surfaces 30 days of phone activity sourced fromPatientMessage where channel='CALL'(the rows the/api/webhooks/ringcentral/callswebhook persists on session-disconnect, both directions). Hero strip: total calls / answer rate / avg duration / unlinked-inbound count (numbers without a patient record β staff reconciliation queue). Daily volume bar chart (in/out split, 30-day window, peak-day callout). Hour-of-day distribution (PT-bucketed, peak-inbound callout β informs front-desk staffing). Top callers table (10 deepest, deeplinked to /admin/patients/[id]#communication). Recording-coverage footer (gates on RC Healthcare BAA). Empty-state copy explains the dormancy when RC env vars aren't set. Tab nav extended on the existing 3 reports pages too β Revenue, Practice health, Booking funnel all now show Calls + CRM tabs alongside. - 𧬠**CRM lifecycle reports β
/admin/reports/crm.** Patient-relationship view tuned to a medical-cannabis clinic: tenure histogram (<1 / 1β2 / 2β5 / 5β10 / 10+ yr buckets, sourced fromPatient.patientSince ?? createdAt); lifecycle funnel by completed-visit count (Lead β 1 β 2 β 3+); cert-renewal funnel (60/30/7 day windows + recently-expired-30d); dormant-patient list (no completed visit in 12+ months, sorted by oldest dormancy); top-20 patients by visit count with tenure / issuing doctor / last records upload date inline; recommending-physician distribution (latest issuingDoctor on file β *history not yet tracked*, see follow-up below); records (MedicalDocument) coverage card; communication engagement card (SMS reply rate fromoutbound:inboundratio, SMS opt-in count, email opt-out count). Doug-flagged dimensions covered: 'how long they've been with us' (tenure histogram + per-patient column), 'rec issuing dr' (latest only β history is a follow-up arc), 'last records upload' (per-patient column + coverage card + recent-uploads ledger). - π **SF inventory script β Doug's 'important cells' allowlist scaffold.**
scripts/sf-inventory.mjsnow has anIMPORTANT_FIELDSmap (per-sobject column allowlist). Empty by default β when Doug provides the column list, dropping it in switches the per-object SOQL sample from 'first 30 fields' to those exact columns. Header comment captures the three columns flagged so far (patientSince, issuingDoctor + history, last records upload) and where they map in the GW schema. Output shape unchanged (stillmigration/sf-inventory-)..json
Changed
- Reports tab nav extended on all 4 existing report pages (revenue/health/funnel + the two new ones) β
Revenue & volume Β· Practice health Β· Booking funnel Β· Calls Β· CRM, withoverflow-x-autoso the row scrolls cleanly on narrow widths.
v2.73.362026-05-07ProductionAdded
- π **In-app softphone β RingCentral Embeddable widget docked in the admin shell.** v2.73.24 shipped a click-to-call button on the patient panel that ringOut'd staff via the REST
/ringoutendpoint (rings desk phone first, then bridges patient β the Salesforce-CTI step-1 pattern). v2.73.36 closes the step-2 loop: the actual softphone UI lives inside the admin app. Newcomponent (admin layout, non-bookkeeper roles) embeds the official RingCentral Embeddable iframe (apps.ringcentral.com β full dialer / SMS / contacts / call-history / voicemail UI for free), handles its own OAuth in a popup so staff sign in once per session, and exposeswindow.rcSoftphoneDial(phone, name)for click-to-dial wiring. CommunicationPanel'scallViaRcnow prefers the in-app dial when the widget is mounted (zero round-trip β staff hear the call in their browser via WebRTC) and falls back to the existing RingOut REST flow when the widget isn't available. Inbound + outbound call rows are still persisted by the/api/webhooks/ringcentral/callswebhook on session-disconnect (handles both directions;direction === 'Inbound' ? 'IN' : 'OUT'), so the in-app dialer doesn't duplicate the call-tracking pipeline β it just gives staff a place to pick up + dial without leaving the admin shell. CSP updated to allowframe-src apps.ringcentral.com service.ringcentral.com,connect-src platform.ringcentral.com media.ringcentral.com(REST + WebSocket), andmedia-src blob: media.ringcentral.com(WebRTC audio stream). Widget UI is a 360Γ600 docked panel bottom-right with minimize / hide controls; auto-pops on inbound ring (rc-call-ring-notifypostMessage event). Env-gated onNEXT_PUBLIC_RC_CLIENT_IDβ fully dormant in any environment that hasn't been wired up. **Doug action when ready:** in the RC developer dashboard for the existing Office@Hand server-only app, addhttps://apps.ringcentral.com/integration/ringcentral-embeddable/latest/redirect.htmlas an authorized redirect URI, setNEXT_PUBLIC_RC_CLIENT_IDin Vercel production, redeploy. (Audio in-browser means staff machines are now in the BAA scope β RC Healthcare tier covers this; the existing TODO RingCentral BAA step still gates flipping the env var on.) Bookkeepers excluded (no PHI access). tsc clean.
v2.73.352026-05-07ProductionAdded
- π **RC webhook renewal β surface failures to /admin/audit-log.**
rc-webhook-renewcron has been daily-firing and idempotent for weeks, but a failed renewal only logged to Vercel function logs (which Doug doesn't watch daily). If the cron returns 200 withfailed: 1, RingCentral inbound SMS + calls have ~6 days of grace before the subscription expires and webhooks silently stop firing β the same silent-outage class fixed in v2.73.34's DELETE-failure surface. Two new audit actions added tolib/audit.ts:RC_WEBHOOK_RENEW_FAILED(any sub failed renewal β alerting signal) andRC_WEBHOOK_RECREATED(a sub had to be recreated because prior renewal was missed β soft drift signal, useful for charting frequency). Cron writes one audit row per fire when either tally is non-zero; detail field carries the per-sub error addresses so triage doesn't need a Vercel log dive. Console.error path retained for the function-level view. No behavior change to RC API calls themselves β purely observability layer on top. Code-only, no vendor lift, dormant until RC env vars are set in production. tsc clean. - π **Salesforce migration prep β
scripts/sf-inventory.mjs(Stage-5 prerequisite).** TODO.md Stage-5 (SF retirement) starts with 'Inventory what Salesforce currently does that the in-app version doesn't yet cover.' Without a concrete inventory we're guessing what to build. New read-only one-shot script: authenticates withSF_CLIENT_ID/SF_CLIENT_SECRET/SF_INSTANCE_URL(same creds the existing/api/integrations/salesforceroute uses), lists every sobject (standard + custom β anything ending in__cis auto-discovered via/sobjects), runsSELECT COUNT()per object + a 5-row sample of the most-recent rows, captures full field metadata (custom-field flag + label) so custom-field surface is concrete. Output:migration/sf-inventory-(folder added to.json .gitignoreβ patient PHI risk, never commit). Standard objects covered: Lead, Contact, Account, Opportunity, Task, Event, Case, EmailMessage, Note, ContentDocument, Campaign, CampaignMember. Read-only against SF (SOQL SELECT + describe), never writes back. Re-runnable: same-day output overwrites; sample size tunable viaSF_SAMPLE_SIZEenv. Run when SF creds are in.env.local, surface what's actually there, *then* decide what the in-app schema needs to absorb before Doug freezes SF writes.
v2.73.342026-05-07ProductionFixed
- π©Ί **HIPAA audit-trail follow-up β 2 more silent
.catch(() => {})paths surfaced.** v2.73.33 fixed the centralaudit()helper. Cross-project bug-hunt 2026-05-07 surfaced two SIBLINGS that escaped that sweep: (1)/api/admin/patients/[id]/send-renewal/route.tsswallowedlogWorkflowEvent()failures (workflow-log gap on every manually-triggered renewal email β auditor can't reconstruct who got contacted when if the row never landed). (2)/api/cron/rc-webhook-renew/route.tsswallowed the RingCentral DELETE-subscription failure β if both DELETE and the subsequent recreate fail, the old subscription expires invisibly and SMS/voice webhooks silently stop firing (the kind of outage where the cron 'returned 200' but the underlying integration is dead). Both fixes mirror the v2.73.33 audit() pattern: replace.catch(() => {})with.catch((err) => console.error('[so Vercel logs surface the failure for triage while the request/cron boundary is preserved. No PHI in either log message β patient-id is internal UUID, RC-failed] ...')) addris the webhook route URL not a phone number. Pre-commit Explore review CLEAN. tsc clean.
v2.73.332026-05-07ProductionFixed
- π©Ί **HIPAA forensic-trail risk β
audit()silently swallowed every write failure.**lib/audit.ts:96had.catch(() => {})on the auditLog write with comment 'never block the request if logging fails' β right principle, wrong implementation. If the auditLog DB write ever failed (FK violation / connection-pool exhaustion / schema mismatch), the original action's success boundary was preserved but the forensic trail had a silent gap. For a HIPAA-bound platform that's a real audit-vector risk: the SOC 2 / HIPAA reviewer asking 'show me every PHI access in the last 90 days' would never know rows were missing. Fix: replace silent.catch(() => {})with an explicit try/catch that callsconsole.error('[audit-write-failed] action=... resource=... staff=... err=...')with the Prisma error code suffix when present (P2002 unique violation, P2003 FK violation, etc.) β same shape as inventoryapp'slib/audit.tswriteAudit catch (v72.515-era pattern). Vercel function logs now surface the failure for triage; the request boundary is still preserved (no throw); the comment was updated to reflect the new principle ('never block the request β but DO surface the failure in Vercel logs'). The 3 call sites with redundant outer.catch(() => {})(already-internally-resolved promises) stay as-is for now β they're cosmetic noise but not bugs. tsc clean.
v2.73.322026-05-07ProductionFixed
- **Drift-class hunt round 4: 2 more TCPA leaks closed in
/api/cron/no-show+/api/cron/doh-nudge.** Both crons send patient emails without honoring the v2.73.21 emailUnsubscribed gate. (1)no-showβ primary effect is the status MARK (operational, must always happen), but the follow-up email is now gated on!appt.patient.emailUnsubscribed(NOT a query filter β wrong shape since the cron must mark all stale appts NO_SHOW regardless of opt-out). Patient.select expanded to includeemailUnsubscribed. (2)doh-nudgeβ purpose IS the email, so query gainspatient: { emailUnsubscribed: false }filter (same shape as v2.73.30 bulk-remind + v2.73.31 intake-reminder). Plus dohNudgeEmail template signature gainsunsubscribeUrl?: string+ threads to shell() for body footer. Cron now passes makeUnsubUrl to template + sendEmail. **Total TCPA leaks closed this session: 5** (cron/reminders + bulk-remind + intake-reminder + no-show + doh-nudge). tsc clean.
v2.73.312026-05-07ProductionFixed
- **Drift-class hunt round 3:
/api/cron/intake-reminderwas missed in v2.73.21 + v2.73.26 batches β three layers fixed.** (1)intakeFormReminderEmailtemplate signature gainsunsubscribeUrl?: string+ threads it toshell()for the body footer. (2) Cron now passesmakeUnsubUrl(patient.email)to both the template + sendEmail (List-Unsubscribe header). (3) **Closed another undocumented TCPA leak** β the appointment query was missingpatient: { emailUnsubscribed: false }filter, so an opted-out patient with no intake done would still get the cron's nudge email. Now filtered. Same drift class as v2.73.21 (cron consent leak) + v2.73.30 bulk-remind. tsc clean.
v2.73.302026-05-07ProductionFixed
- **Drift-class hunt round 2: 4 admin manual-send routes now match the v2.73.21 + v2.73.23 + v2.73.26 patterns end-to-end.** The cron-side path was fully gated, but admin-triggered manual sends were missed. Sites fixed: (1)
/api/admin/patients/remind(renewalReminderEmail + renewalEscalationEmail) β now passesunsubscribeUrlto template + sendEmail. (2)/api/admin/patients/[id]/send-renewal(winBackEmail + reEngagementEmail) β same. (3)/api/admin/patients/bulk-remind(renewalReminderEmail + renewalEscalationEmail) β passes unsubscribeUrl AND closes a previously-undocumented TCPA leak: the patients query was missingemailUnsubscribed: falsein the where clause, so a bulk-send admin click would re-engage opted-out patients (revocation violation). Now filtered. (4)/api/admin/appointments/no-show(noShowEmail) β passes unsubscribeUrl. Pairs with v2.73.29 admin-email-preview signature alignment. tsc clean.
v2.73.292026-05-07ProductionFixed
- **
/admin/email-previewroute now passesunsubscribeUrl: UNSUBto bookingConfirmationEmail + reminderEmail (48h + 2h) previews.** v2.73.23 added theunsubscribeUrlparameter to those two templates' signatures + threaded it toshell()for the body footer; the email-preview route was missed in the same commit. Result: admins reviewing template appearance saw the customer-facing email WITHOUT the unsubscribe footer that production callers (cron/reminders + integrations/email + admin/resend-confirmation + admin/[id]/remind) actually emit. Drift made template-review misleading. 4 sites fixed (booking-confirm telehealth + in-person variants + reminder 48h + reminder 2h). Pure preview-data-shape edit; no runtime change. tsc clean.
v2.73.282026-05-07ProductionAdded
- **NextStepsBanner gains PENDING_APPROVAL branch.** Audit-recommended priority-3 detection that v2.73.22 v0 deferred. When a patient logs in 8h after their visit + has no upcoming appointments + their cert is still in provider review, they previously saw nothing at the top of /patient/portal β implicit message was "nothing's happening" when in fact their cert was being finalized. Now: amber-toned informational card "Your authorization is being finalized β Your provider is signing off β most certs land in 24-48 hours. We'll email it to you the moment it's ready." No CTA (informational-only; the existing /authorization-status section below has the per-appointment detail). Component now accepts
pendingApprovalprop alongsideupcomingβ populated bypast.filter(a => a.status === "PENDING_APPROVAL")in portal/page.tsx. NextStepsCard signature gains optionalhref+ctaso info-only renders skip the button entirely. Priority order is now: missing-intake β previsit-due-within-48h β telehealth-imminent β cert-in-review (this branch) β null (per-appointment cards cover the all-set case). tsc clean.
v2.73.272026-05-07ProductionFixed
- **Patient-flow decorative-emoji a11y sweep.** Two emoji wrappers on patient-facing token-gated pages were rendering without
aria-hidden, so screen readers announced the glyph before the actual heading. Mirror of the SCC-side a11y pattern (3Γ π± wrap on /rewards in v4.825 + π on /rewards/balance in v4.845). Sites fixed: (1)/confirm/[token]/page.tsxβπ(H1 "Confirm your appointment" carries the meaning); (2)/checkin/[token]/page.tsxβ the dynamic{isPast ? "β°" : isCancelled ? "β" : "β "}glyph (H1 "Appointment ended/cancelled/completed" carries the meaning). One-attribute edit per site. tsc clean.
v2.73.262026-05-07ProductionAdded
- **List-Unsubscribe headers extended to all 5 remaining email-cron + admin-manual send paths.** Mechanical follow-up to v2.73.25 (which only wired the appointment-reminders cron). Same one-line
unsubscribeUrl: makeUnsubUrl(...)opt added to: (1)/api/cron/no-show/route.tsβ no-show notification (1 site); (2)/api/cron/new-patient-drip/route.tsβ Day-3 + Day-14 drip emails (2 sites); (3)/api/cron/renewals/route.tsβ renewal-reminder + renewal-escalation + re-engagement + win-back emails (4 sites); (4)/api/admin/appointments/resend-confirmation/route.tsβ admin-triggered resend (1 site); (5)/api/admin/appointments/[id]/remind/route.tsβ admin-triggered manual reminder (1 site). Total: 9 sendEmail call sites across 5 files now emitList-Unsubscribe+List-Unsubscribe-Postheaders in addition to the body-link footer. Closes the email-side opt-out arc β every patient-facing email path now offers both the Gmail/Apple Mail one-click button AND the body-link fallback. tsc clean.
v2.73.252026-05-07ProductionAdded
- **RFC 8058 one-click unsubscribe headers** β pairs with the v2.73.23 body-link footer to give Gmail / Apple Mail / Yahoo a one-click unsubscribe button at the top of the email. Three coordinated changes: (1)
src/lib/email.tsSendEmailOptionsgainsunsubscribeUrl?: string. When set, sendEmail auto-buildsList-Unsubscribe:+, List-Unsubscribe-Post: List-Unsubscribe=One-Clickheaders. Both Postmark (viaHeaders: [{Name, Value}]body field) + Resend (viaheaders: {...}) carry the headers; SES stub threaded for parity. (2)src/lib/workflow.ts sendEmail()wrapper signature gains optionalopts?: { unsubscribeUrl?: string }forwarded to the impl. Backwards-compatible β existing callers don't pass opts and continue to work. (3)src/app/api/unsubscribe/route.tsgains a POST handler (was GET-only). RFC 8058 requires POST for the one-click path; without this Gmail silently won't render the button despite the header. POST + GET share aflipUnsubscribed(token)helper to guarantee identical effect; POST returns small JSON (email clients don't render the body, just the response code). (4)/api/cron/remindersnow passesunsubscribeUrlto sendEmail in addition to the body link β appointment reminders are the highest-volume customer-facing email path. Other crons (no-show / drip / renewals / admin manual sends) follow the same wiring pattern in subsequent ships. Pre-commit Explore review caught the GET-only bug; fixed in same pass. tsc clean.
v2.73.242026-05-07ProductionAdded
- **RC Call (click-to-call) button on patient communication panel.** Doug 2026-05-07: "the att office@hand softphone connector like we currently have integrated into our salesforce." Step 1 of CTI parity. New "RC Call" button on
CommunicationPanel.tsxadmin-side, placed before the existing nativetel:link (which is renamed "Dial" to clarify its fallback role). Click-to-call POSTs to the existing/api/admin/messages/callroute (RingOut β rings the staff phone first, then dials the patient, keeping the staff number hidden). Auto-logs the OUT CALL row to PatientMessage on success; the calls webhook patches durationSec on session-disconnect (mirror of Salesforce CTI auto-logging). Staff RC extension/phone cached in localStorage (rc_from_extensionkey) so the prompt fires once per device β future arc: store on admin User model. Graceful 503 fallback when RC env vars aren't yet set ("RingCentral isn't configured yet β use the native Dial link for now"). Pre-commit Explore review: VERDICT=clean (admin-gated route via proxy.ts admin_session_v2, no PHI leaks, optimistic UI is webhook-reconciled). What's NOT shipped yet: embedded WebRTC softphone widget (persistent CTI panel + presence indicator + inbound-call screen-pop). Adding that is a separate arc β needs@ringcentral/ctiSDK install + 15-20 hr dev time. tsc clean.
v2.73.232026-05-07ProductionAdded
- **Unsubscribe link in transactional email templates** β booking confirmations + 48h/24h appointment reminders now carry the same unsubscribe footer that no-show / renewal / re-engagement / win-back emails already had. Patient who receives a transactional email can opt out of marketing emails without logging in (the link points at
/api/unsubscribe?token=...which flipspatient.emailUnsubscribed = true). Closes the audit-2026-05-07 finding that the most-frequently-received customer emails (booking confirm + reminders) had no inline unsubscribe affordance β patients had to log into /patient/portal ProfileCard to opt out, which most won't bother to do, leading to STOP-text-spam or worse-rated complaint mail. Pairs with the v2.73.21 consent-leak fix (cron crons now honoremailUnsubscribed) β together they close the loop: patient toggles via banner link OR portal toggle, both reach the same flag, all crons honor it. **Architectural note:** the existinglib/unsubscribe-token.tshelper base64url-encodes the patient email in the URL (4 other crons use it pre-existing). For a HIPAA-bound app this is acceptable in BAA-covered access-log scope (Vercel BAA in place), but a future arc should swap to an opaque random token + DB lookup pattern (matchescancelTokenshape on Appointment). Tracked as follow-up; this commit doesn't make the existing exposure worse, just extends the pattern to 2 more templates so customers get the inline opt-out everywhere. tsc clean.
v2.73.222026-05-07ProductionAdded
- **Patient portal NextStepsBanner β top-of-page "what's needed from you next" surface.** Doug 2026-05-07: "have the system be intuitive on what is needed from the patient to move forward." New
atsrc/app/patient/portal/_components/NextStepsBanner.tsxlifts the highest-priority pending action out of the per-appointment cards into a single, unmissable card above the Upcoming section. Three priority branches: (1) missing intake form on a SCHEDULED/CONFIRMED visit (amber, "Complete your intake before [tomorrow / in 3 days / May 12]"); (2) pre-visit form pending within 48h (teal, matches existing per-appointment showPreVisit gate exactly); (3) telehealth visit starting within 30 min (green, "Open visit page" CTA). Renders null when nothing is action-needed β keeps the page tight; the per-appointment "you're all set" banners cover the confirmation case. Date-display usesfmtPTfrom@/lib/tzso "today / tomorrow" labels respect America/Los_Angeles wall-clock day even across DST transitions and the UTCβPT day boundary (a naivefloor(diffMs / 86400000)would mis-classify near-midnight visits β patient-facing dates everywhere else in the portal go throughfmtPTfor the same reason). Pre-commit Explore review: VERDICT=needs-fix on first pass (DST-unsafe date math + color-contrast question). Fixed: DST corrected to usefmtPTcalendar-day diff. Color-contrast was a false positive β white-on-#2d6a4f passes WCAG AA at ~7:1. tsc clean.
v2.73.212026-05-07ProductionFixed
- **TCPA / consent leak in appointment-reminder send paths β 4 sites.** Audit 2026-05-07 found four send paths that honored only one layer of the two-layer consent model. Patient consent in this app has TWO flags: appointment-level (
appt.smsConsentset at booking, alsoappt.emailConsentvia patient.emailUnsubscribed) AND current patient-level (patient.smsConsent+patient.emailUnsubscribedβ flipped by /patient/portal ProfileCard toggles AND by RingCentral inbound STOP/START webhook at/api/webhooks/ringcentral/sms/route.ts:91). Both must be respected to honor TCPA revocation-at-any-time. **Fixed sites:** (1)/api/cron/reminders/route.tsβ email path was missing the patient.emailUnsubscribed gate; SMS path was missing patient.smsConsent gate. (2)/api/cron/reminders-2h/route.tsβ query filter now requirespatient: { smsConsent: true }in addition to existing appt-level filter. (3)/api/admin/appointments/[id]/remind/route.tsβ admin manual reminder endpoint now honors both layers on email + SMS (an opted-out patient should be unreachable via the admin-button flow; admin can still RC-click-to-call which logs to PatientMessage). (4)/api/integrations/sms/route.tsβ booking-confirmation SMS now requires patient.smsConsent in addition to appt.smsConsent (a returning patient who previously texted STOP doesn't get re-engaged just because they made a new booking). Comment blocks added at all four sites explaining the two-layer model so future agents don't drop one layer accidentally. tsc clean.
v2.73.202026-05-07ProductionAdded
- Pre-cutover legacy URL preservation in
next.config.tsredirects() β 25 permanent (308) redirects mapping the WordPress greenwellness.org URL set (Rank Mathsitemap_index.xml, 22 pages + 9 posts verified 2026-05-07) onto the new Next.js routes. Without these, every Google-indexed inbound link would 404 on cutover day. Same-page-different-name swaps (/faqs β /faq, /privacy-policy β /privacy, /terms-of-service β /terms, /find-a-clinic β /locations, /medical-professionals β /providers, /why-green-wellness β /about). Booking-flow legacy (/book-now + /get-started β /?book=1). City SEO landing pages (/olympia-medical-marijuana-card β /telehealth/olympia-telehealth; Lynnwood + Spokane Valley β /telehealth index β those cities don't have dedicated landing pages on the new site). Blog posts β /learn or /conditions per topical relevance. /contact β /about for now (open question for Doug whether to graduate /contact to its own route post-cutover). Doug 2026-05-07: 'we may want to keep the addresses' β this closes that gap. Pairs with the existing /hipaa-notice β /privacy redirect.
v2.73.192026-05-07ProductionRemoved
- Dead
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? '...'declarations from 6 pages (/dispensaries, /faq, /refer, /terms, /providers/[slug], /conditions/[slug]). Now that those pages flow metadata throughbuildPageMetadata()(uses internalSITE_URL), the local APP_URL constant is unreferenced. Pure cleanup β no behavior change.
v2.73.182026-05-07ProductionChanged
- Last 3 dynamic-route generateMetadata blocks adopt
buildPageMetadata(): /locations/[city], /telehealth/[city]/[condition], /locations/[city]/[condition]. **All public pages β static AND dynamic β now generate metadata through the seo.ts helper.** Per-route canonical, OG image, Twitter card all flow from one shared function. Hand-rolled metadata footprint reduced to zero on indexable pages.
v2.73.172026-05-07ProductionChanged
- /telehealth/[city] β both
generateMetadatabranches (state-page + city-page) adoptbuildPageMetadata(). Was duplicating ~30 lines of metadata shape twice; now one helper handles both. Same per-record dynamic metadata. /telehealth/[city]/[condition] + /locations/[city] + /locations/[city]/[condition] still hand-roll generateMetadata; next ticks.
v2.73.162026-05-07ProductionChanged
- /conditions/[slug] + /providers/[slug] β adopt
buildPageMetadata()inside theirgenerateMetadataasync functions. Two more dynamic-route pages on the helper. /learn/[slug] already migrated by Doug in v2.73.0; telehealth/[city] + locations/[city] + nested still hand-roll; next ticks.
v2.73.152026-05-07ProductionChanged
- /telehealth + /dispensaries β adopt
buildPageMetadata(). **Every top-level public page now flows metadata through the seo.ts helper.** Per-route canonical, OG image, Twitter card all generated from one helper call. The dynamic[slug]and[city]/[condition]pages still hand-roll metadata (their generateMetadata functions; not a single-call refactor); next ticks.
v2.73.142026-05-07ProductionChanged
- /learn, /providers, /locations β adopt
buildPageMetadata(). All major top-level public pages now use the shared metadata helper. Telehealth, telehealth/[city] + nested still hand-roll metadata; will pick up next ticks.
v2.73.132026-05-07ProductionChanged
- /about, /conditions, /pricing β adopt
buildPageMetadata(). Three more pages on the helper. Keywords-array stays inline on /pricing (page-specific). /learn next tick.
v2.73.122026-05-07ProductionChanged
- /faq metadata β adopt
buildPageMetadata()from seo.ts. Title, description, canonical, OG image, Twitter card now flow from one helper call. Keywords-array stays inline (page-specific). Other top-level pages (/about, /conditions, /pricing, /learn) still hand-roll metadata; will pick up next ticks.
v2.73.112026-05-07ProductionChanged
- /pricing β last hand-rolled BreadcrumbList migrated to
buildBreadcrumbLd(). Was missed in the v2.73.4 sweep. **Now truly every public page** with breadcrumb schema uses the shared helper.
v2.73.102026-05-07ProductionChanged
- Last 3 inline FAQ schemas migrated to
buildFaqPageLd(): /telehealth/[city]/[condition], /locations/[city], /locations/[city]/[condition]. **Every public page emitting FAQPage JSON-LD now uses the shared seo.ts helper** β full FAQ schema consolidation complete.
v2.73.92026-05-07ProductionChanged
- /telehealth and /telehealth/[city] β adopt
buildFaqPageLd(). The /telehealth page also got a small refactor: extracted the inline FAQ array to aTELEHEALTH_FAQconst so the visible FAQ section iterates from{ q, a }shape (cleaner) and the JSON-LD reads the same source. Single source of truth for both the visual list and the schema. Three FAQ pages still inline (locations + 2 nested matrix pages); next ticks.
v2.73.82026-05-07ProductionChanged
- /faq, /providers/[slug], /conditions/[slug] β adopt
buildFaqPageLd()from seo.ts. Replaces hand-rolled FAQPage JSON-LD on three pages with one helper call. Five pages with FAQ schema remain (telehealth + nested + locations); will pick up next ticks.
v2.73.72026-05-07ProductionChanged
- /providers/[slug] β Physician JSON-LD now uses
buildPhysicianLd()from seo.ts. Replaces 30 lines of hand-rolled schema with one helper call. Output is identical (same workLocation, hasCredential, knowsAbout, medicalSpecialty fields).
v2.73.62026-05-07ProductionChanged
- Telehealth + locations city/condition matrix pages β adopt
buildBreadcrumbLd()from seo.ts on the last 3 inline-breadcrumb pages (/telehealth/[city],/telehealth/[city]/[condition],/locations/[city]/[condition]). **Every public page that emits BreadcrumbList JSON-LD now uses the shared helper** β full seo.ts adoption complete for breadcrumb schemas.
v2.73.52026-05-07ProductionChanged
- /providers/[slug], /conditions/[slug], /refer β adopt
buildBreadcrumbLd()from seo.ts. Three more pages migrated. Three pages still inline (telehealth/[city] + nested, locations/[city]/[condition]); will pick up next ticks.
v2.73.42026-05-07ProductionChanged
- /faq, /learn, /about, /conditions β adopt
buildBreadcrumbLd()from seo.ts. Four more pages migrated off inline BreadcrumbList JSON-LD. Six pages still inline (telehealth/[city] + nested, providers/[slug], conditions/[slug], locations/[city]/[condition], refer); will pick up next ticks.
v2.73.32026-05-07ProductionChanged
- /providers and /dispensaries pages β replaced inline
BreadcrumbListJSON-LD withbuildBreadcrumbLd()fromseo.ts(introduced v2.73.0). Cleaner two-line trail definition + automatic SITE_URL prefixing. Continues the seo.ts adoption from v2.73.2. Eight more pages still have inline breadcrumb LD; will pick those up incrementally.
v2.73.22026-05-07ProductionChanged
- Homepage JSON-LD β switched from local
home-structured-data.tsconsts to the centralbuildMedicalBusinessLd()+buildWebSiteLd()helpers insrc/lib/seo.ts(introduced in v2.73.0). The central helpers addlegalName,medicalSpecialty: schema.org/Cannabis,areaServed.sameAs(Wikidata), and an E.164-normalized telephone field β all stronger SEO signals than my hand-rolled version. Deleted the now-redundanthome-structured-data.ts. One source of truth for the homepage's MedicalClinic + WebSite schemas.
v2.73.12026-05-07ProductionChanged
- /learn/[slug] and /conditions/[slug] β adopt shared
. Wraps up the SiteFooter consolidation pass started in v2.72.8. Every public marketing page now renders its footer from the shared component β only/refer(intentionalmt-8spacing) and/privacy(the privacy page itself) keep inline footers. (Note: the actual code change shipped concurrently with v2.73.0; this entry just records the version-bump for footer-version-tracking.)
v2.73.02026-05-07ProductionAdded
- Phase 13a SEO foundation β
src/lib/seo.tscentral JSON-LD + metadata helper module. Pure server-only functions:buildMedicalBusinessLd,buildLocationLd,buildPhysicianLd,buildArticleLd(Article + MedicalWebPage compound β YMYL signal Google weights heavily for medical content),buildFaqPageLd,buildBreadcrumbLd,buildPageMetadata. Mirrored fromCannAgent/src/lib/seo.tsshape, retuned for medical context. Future pages call one helper instead of inlining ~30 lines of schema each. - Per-route canonical + OG card metadata on the 7 public pages that were missing it:
/(canonical),/refer(new layout.tsx since the page is client-side),/refer/[code](generateMetadatareads the promo so the OG card personalizes the discount amount and code),/leave-a-review,/privacy,/terms,/changelog. All routed through the newbuildPageMetadatahelper for consistency. - /learn/[slug] articles upgraded to
Article+MedicalWebPagecompound JSON-LD viabuildArticleLd. AddsmedicalAudience: Patient,inLanguage: en-US,articleSection,isFamilyFriendly. Replaces the hand-rolled inline schema.
Changed
- Pre-launch metadata coverage on customer-facing public routes: 100% of indexable pages (per
/robots.ts) now shipmetadata+alternates.canonical+openGraph.images. Admin / intake / patient-portal / provider / dispensary / cancel / checkin / reschedule / my-appointments routes correctly remain blocked from SEO viarobots.tsβ they are HIPAA-adjacent and should not be indexed.
v2.72.122026-05-07ProductionChanged
- /locations and /locations/[city] β adopt shared
. Continues the SiteFooter consolidation. Both pages already had inline-footer markup with the privacy link; now they render from the shared component.
v2.72.112026-05-07ProductionChanged
- /pricing, /faq, /about β replaced inline footers with shared
. They already had the HIPAA Privacy link, but were duplicating the markup. Continues the SiteFooter consolidation started in v2.72.8 β every public-page footer now renders from one place.
v2.72.102026-05-07ProductionChanged
- /refer page footer β added missing HIPAA Privacy Notice link inline (kept the existing
mt-8spacing). - /providers/[slug] page β replaced inline footer with shared
. Was missing the HIPAA Privacy Notice link.
v2.72.92026-05-07ProductionChanged
- /learn, /providers, /conditions β replaced inline footers with shared
. All three previously had hand-rolled footers that included brand + copyright but were missing the HIPAA Privacy Notice link present on /pricing, /faq, /about, /refer. The shared component now keeps all inner-page footers in sync β one place to update.
v2.72.82026-05-07ProductionAdded
- Shared
component (src/components/layout/SiteFooter.tsx) β brand + copyright + HIPAA Privacy Notice link. Many top-level public pages had drifted into having no footer at all (/telehealth,/dispensaries,/leave-a-review), leaving visitors with no privacy link or brand reinforcement at the bottom of long reads. Imported on each of those three pages now. The full marketing-grade footer (4-col nav + version badge) on the homepage is intentionally separate. - /dispensaries page β added missing
(was rendering with NO header, no nav back to home or other sections β visitors who landed via search were stuck on a single page).
v2.72.72026-05-07ProductionChanged
- WhyUs comparison table β
Typical online clinicβOther clinics(cleaner, balanced label) and bumped both columns tow-24(96px). Wasw-20for Green Wellness vsw-16for the competitor, which forced 'Typical online clinic' to wrap to 3 lines on every breakpoint. Now both columns are equal width and labels fit on one line. - /about page β added the standard inner-page minimal footer (brand + copyright + HIPAA Privacy Notice link). The page previously ended with no footer at all, leaving visitors with no privacy link or brand reinforcement at the bottom of a 1500-word read. Matches the footer pattern on /pricing, /faq, /conditions, /learn.
v2.72.62026-05-07ProductionFixed
- PHI leakage in logs β
lib/twilio.tsandlib/ringcentral.tswere both logging the full phone number on validation failure ([SMS] Invalid phone number: ${to}). Phone numbers attached to medical-care context are HIPAA-protected, and Vercel logs aren't BAA-covered. Now redacted to a generic 'failed E.164 normalization' message β format-failure is enough to debug a malformed input without recording PHI. - /api/og β clamp
title(120 char),subtitle(200 char),badge(80 char) on user-supplied query params. A hostile URL with a 1MB title would have made the renderer chew CPU on a cache MISS; a 24h edge cache then absorbs that one render but a fan-out across distinct URLs could pile up. The clamp truncates rather than rejecting β legitimate long titles still render, just shorter.
v2.72.52026-05-07ProductionChanged
- Reviews section trust card β
HIPAA-Protected VisitsβHIPAA-Aware Privacy(consistent with the new TrustBar copy) and amplified the description to call out TLS-in-transit. Same accuracy bar β no overclaiming.
v2.72.42026-05-07ProductionChanged
- TrustBar β expanded from 4 inline labels to 6 badges with the trust signals patients screen medical sites for: WA-licensed physicians, HIPAA-aware design, TLS-encrypted in transit, Stripe-secured payments, 4 clinic locations, telehealth statewide. Added a 'See our HIPAA Notice of Privacy Practices' line linking to /privacy below the badges so trust-conscious patients are one click from the underlying policy. All claims are accurate today (no overclaiming HITRUST/SOC2). 'HIPAA-aware design' will flip to 'HIPAA-compliant' once the vendor BAAs (Anthropic, Postmark/SES, RingCentral) close.
v2.72.32026-05-07ProductionFixed
- Webhook signature verification β fail-CLOSED in production. Both
lib/rc-webhook.ts(verifyToken) and/api/webhooks/twiliopreviously had a 'dev fallback': if the verification secret env var was unset, the signature check was skipped and any POST was accepted. Since RC/Twilio integrations are vendor-blocked, the secrets are unset in production β meaning anyone on the internet could POST to/api/webhooks/ringcentral/sms,/api/webhooks/ringcentral/calls, or/api/webhooks/twilioand: inject rows into PatientMessage (PHI table), or trigger STOP-word handling to silently unsubscribe real patients from SMS by guessing phone-number digits. Now: production rejects the request when the secret is missing; dev keeps the open behavior for local testing.
v2.72.22026-05-07ProductionFixed
- /api/waitlist β added per-IP rate limiting (10/hr, matches /api/appointments). Was the only public unauthenticated POST without a rate limit, leaving it spam-floodable: an attacker iterating distinct emails could pile up DB rows AND fire the waitlist-notify cron's email path on every entry. The existing email+type dedupe protects honest dupes; the rate limit is for hostile fan-out.
Changed
- /admin/launch β wrapped the
information_schemamigration-introspection query inunstable_cache(1h, key includes the build SHA so a fresh deploy auto-invalidates). The result only changes when a migration runs against Neon, so the per-request$queryRawUnsafeagainst information_schema was waste. Other launch-page queries intentionally stay un-cached β Doug refreshes this dashboard specifically to verify a fix landed, and a cache there would mask the signal.
v2.72.12026-05-07ProductionAdded
- Strict CSP rollout β Report-Only mode on protected routes (admin/provider/dispensary/patient). proxy.ts generates a per-request nonce, sends
Content-Security-Policy-Report-Onlywith'nonce-XXX' 'strict-dynamic'(no'unsafe-inline'), and the existing lax CSP from next.config.ts continues to enforce. New /api/csp-report endpoint receives violation reports, redacts URL paths to first two segments (PHI-safe), rate-limits per IP, and writes one AuditLog row per violation with action=CSP_REPORT_ONLY_VIOLATION. **Flip-to-enforcing path documented in proxy.ts code comments**: when /admin/audit-log shows zero CSP_REPORT_ONLY_VIOLATION entries for a week of real admin usage, change the response header from Report-Only to Content-Security-Policy and remove the lax CSP from next.config.ts for protected routes. - MedicalClinic + WebSite JSON-LD moved from root layout to homepage page.tsx (and into new src/lib/home-structured-data.ts). Inner pages no longer duplicate the canonical entity schema β Google's preferred pattern. Also stops admin pages from rendering inline scripts that would flood the new Report-Only audit log with expected violations.
v2.72.02026-05-06ProductionChanged
- Homepage Hero β server-render the patient-count + approval-rate + next-slot data instead of fetching post-hydration.
src/app/page.tsxis now async, fetches both via newgetHomepageStats()+getNextSlotDisplay()(unstable_cache 5min revalidate, mirrors the cache window of /api/public/stats + /api/public/next-slot). PassesinitialStats+initialNextSlotthrough HomeContent β Hero. Hero seeds its useState from those props so the social-proof row appears in the initial HTML rather than popping in after JS loads β meaningful CLS + LCP win on the marketing surface. The post-mount refresh effect still runs so long-lived tabs pick up new bookings.
v2.71.92026-05-06ProductionChanged
- Vercel Analytics β filter out internal-team traffic via
beforeSend. Pageviews and custom events under/admin,/provider/portal,/dispensary, and/patient/*are dropped before they reach the dashboard. Public-facing analytics now reflect only patient + prospect behavior β not Doug, schedulers, providers, or dispensary staff using the app internally. Speed Insights still tracks all routes (operational health is the right scope for that one).
v2.71.82026-05-06ProductionAdded
- Booking-funnel telemetry β three new Vercel Analytics events on the scheduling wizard:
wizard_opened(withisReturning),wizard_abandoned(with the last step the user reached), andbooking_failed(with HTTP status β 409 for slot-taken, 0 for network error, 5xx for server). Combined with the existing per-advancewizard_stepevents, the analytics dashboard can now answer: where do users drop off, what fraction of opens become completions, and how often does a real backend failure block a booking.
v2.71.72026-05-06ProductionChanged
- HomeContent β moved
SchedulingWizardto anext/dynamicimport (matches the existingChatWidgetDynamicpattern). The wizard pulls in framer-motion + the full Stripe.js SDK + 7 step components, but ~99% of homepage visitors never click 'Book'. Now: that bundle only loads the moment a user opens the wizard. Initial bundle on the homepage shrinks meaningfully; the wizard's first-open is one extra chunk fetch (cached after that).
v2.71.62026-05-06ProductionChanged
- AnnouncementBanner (root layout) β wrapped in
unstable_cachewith tagsite-settingsand 1h revalidate. Was hittingdb.siteSettings.findUniqueon every page render across the entire app. Now: one cached read for an hour, instant invalidation when the admin edits via /admin/settings (PATCH handler now callsrevalidateTag('site-settings', { expire: 0 })per Next 16's two-argument signature). Lower TTFB on every SSR page across the site, no admin-side UX regression.
v2.71.52026-05-06ProductionChanged
- /api/locations β added edge cache headers (60s fresh + 5min stale-while-revalidate). Hit on every homepage load and every booking-wizard step 3, but locations rarely change. Browser-side: must-revalidate (instant fresh data on every navigation that uses the cached entry); edge-side: served from cache for 60s. Admins see newly-added locations within ~60s; public traffic gets near-instant responses.
v2.71.42026-05-06ProductionChanged
- next.config.ts images β added AVIF to
formats(was WebP-only). AVIF is ~20-30% smaller than WebP at equivalent quality, with 96%+ browser support; Next falls back to WebP automatically for older Safari. Also bumpedminimumCacheTTLfrom 4 hours (Next 16 default) to 30 days. Vercel Blob gives every upload a unique URL, so a replaced headshot is a new URL β bumping TTL doesn't cause stale-image issues. Provider headshots and marketing assets now serve materially smaller bytes per pageview.
v2.71.32026-05-06ProductionChanged
- OG image route (/api/og) β added
Cache-Control+Vercel-CDN-Cache-Controlheaders (24h fresh, 7d stale-while-revalidate at edge). Same query params always produce the same image, but every social crawler hit was returningx-vercel-cache: MISSand regenerating the 30KB PNG. Now cached at the edge across crawler hits β Twitter, LinkedIn, Slack, Facebook all share the same warm response.
v2.71.22026-05-06ProductionChanged
- Sitemap β drop
lastModifiedfrom URLs whose source has no real per-entry change date (locations, providers, static pages, conditions, telehealth, state pages, matrix). Articles still carry a realpublishedAt. Per Google's published guidance,is ignored when not consistently accurate, and writing a freshnew Date()on every URL at build time was that exact failure mode β wasted signal across hundreds of URLs. Articles' real dates now have a chance of being honored.
v2.71.12026-05-06ProductionChanged
- OG image route (/api/og) β dropped
runtime = 'edge'. Vercel's current guidance recommends Fluid Compute (Node default) over edge β same regions, same price, full Node compat, fewer surprises. ImageResponse works identically in node. No visible difference for users; one less compatibility footgun for future maintainers.
v2.71.02026-05-06ProductionAdded
- Audit logging on structural send failures β workflow.sendEmail and workflow.sendSms now audit EMAIL_INTEGRATION_FAILURE / SMS_INTEGRATION_FAILURE when the underlying vendor returns false AND no vendor is configured. Surfaces cron-level silent failures to /admin/launch's 'Recent failures' section. Vendor-side errors (SDK throws, rate limits) still log to console but aren't audited β avoids noise on real outages. Recipient PII is masked in the audit detail (HIPAA-safe).
v2.70.42026-05-06ProductionFixed
- PATCH /api/admin/appointments/[id]/status β generic status-change PATCH handler (used from /admin/today + AppointmentCard) was the last duplicate of the silent-notify pattern. Now returns notified + alreadyNotified for COMPLETED/CANCELLED/NO_SHOW. CONFIRMED treated as alreadyNotified=true since no email is expected. Wraps up the show-truth-not-lies thread (v2.69.4 β v2.70.4).
v2.70.32026-05-06ProductionFixed
- AppointmentActions on patient detail page (/admin/patients/[id]) β generic post() helper now surfaces the notified flag from cancel/reschedule/complete/no-show responses. Same warning toast as the appointments-list version: 'Status changed β but the patient was NOT emailed (no vendor configured). Call them.' Closes the last duplicate of this pattern across admin entry points.
v2.70.22026-05-06ProductionFixed
- /admin/providers 'Send password setup' button β was claiming success even when no email vendor was configured (the /api/provider/forgot-password endpoint always returns 200 for anti-enumeration). Now pre-empts with a clear alert: 'No email vendor configured β the password setup link cannot be emailed. Wire up at /admin/launch.' Avoids the misleading green checkmark when nothing actually went out.
Added
- GET /api/admin/email-status β tiny endpoint returning { configured, provider } so client pages can pre-flight before showing 'send' buttons.
v2.70.12026-05-06ProductionFixed
- Cert authorization flow (/api/admin/appointments/approve) β was always showing 'Authorized' on success even when the post-appointment email with cert PDF attachment failed silently. Now the API returns notified + alreadyNotified flags, and the AuthorizeButton shows a clear amber 'Cert PDF generated, but the patient was NOT emailed (no email vendor configured). Download and send manually.' warning when delivery failed. Idempotent retries (alreadyNotified=true) suppress the warning correctly.
v2.70.02026-05-06ProductionFixed
- Reschedule, complete, and no-show appointment flows β same silent-notify fix as v2.69.9 (cancel). All three APIs now return a
notifiedboolean. UI distinguishes between 'fully handled β patient notified by email' (success toast) and 'status changed in the system, but the patient was NOT emailed (no vendor configured)' (error toast prompting manual call). Status change itself still completes either way. - Complete + no-show endpoints also return
alreadyNotifiedso the toast shows 'marked complete' (without the email line) when the email was already sent in a prior call β avoids ambiguous 'NOT emailed' warnings on idempotent retries.
v2.69.92026-05-06ProductionFixed
- Cancel appointment flow β was always toasting 'success' even when the patient cancellation email failed silently (no vendor configured). Now the API returns a
notifiedboolean, and the UI shows 'Cancelled in the system, but the patient was NOT emailed (no vendor configured). Call them.' as an error toast when notification failed. The cancellation itself still completes β only the toast distinguishes between fully-handled and needs-manual-followup outcomes.
v2.69.82026-05-06ProductionChanged
- BulkRemindButton on /admin/patients β when 0 of N reminders went out (no email/SMS vendor configured), now shows a clear error toast pointing at /admin/launch instead of an ambiguous info-toned 'Sent 0 of N reminders' message. Successful sends still toast as success.
v2.69.72026-05-06ProductionFixed
- Resend confirmation button on /admin/appointments/[id] β was showing 'Sent!' even when no email vendor was configured. Now the API returns 502 with a clear error message when sendEmail() returns false, and the button surfaces that error message via toast.
v2.69.62026-05-06ProductionFixed
- Patient detail 'Send renewal reminder' button β was showing 'Renewal reminder sent.' even when no email/SMS vendor was configured (the API returns 200 with emailSent:false silently). Now mirrors the v2.37.0 fix on the appointments-side variant: shows which channels actually delivered, or surfaces a clear 'Not sent β no vendor configured. Check /admin/launch.' error if both fail.
v2.69.52026-05-06ProductionFixed
- Patient communication panel: SMS and Email send buttons now hide when no vendor is configured (instead of showing and erroring on click). SMS gates on RC or Twilio env vars; Email gates on Postmark, SES, or Resend. Same pattern as the AI Draft fix in v2.69.4 β clean absence pre-launch, automatic appearance when vendor env vars land.
v2.69.42026-05-06ProductionFixed
- AI Draft button on patient communication panel β was visible whether or not AI_DRAFTS_ENABLED was set, leading to a confusing 503 error when staff clicked it pre-BAA. Now the button only renders when the env flag is true; clean removal until the Anthropic BAA is signed and the flag flips on.
v2.69.32026-05-06ProductionChanged
- /admin/dispensaries empty state β now shows store icon + brief explanation that partners need a signed BAA before activation, and an 'Add your first dispensary' CTA. Completes the empty-state polish pass across all admin index pages.
v2.69.22026-05-06ProductionChanged
- GO_LIVE_CHECKLIST.md β code-side launch-prep section rewritten to reflect everything shipped this session: 11-section /admin/launch cockpit, 5 smoke tests, 3 email previews, staff activity tracking, login history, /locations CRUD, empty-state polish across 5 pages. Marks code-side launch prep as complete.
v2.69.12026-05-06ProductionChanged
- /admin/waitlist empty state β now shows a friendly card explaining that an empty waitlist usually means slot inventory is healthy, instead of a flat 'No one on the waitlist right now.'
v2.69.02026-05-06ProductionAdded
- /admin/locations: 'Add location' button + inline create form. Previously the page only edited existing locations β Doug had to seed the Location table via Prisma directly. Now he can add locations end-to-end from the UI as part of Day 3 launch prep.
- POST /api/admin/locations β ADMIN/MANAGER gated, Zod-validated. Audits as CREATE_LOCATION.
- /admin/locations empty state β friendly card with map-pin icon and 'Add your first location' CTA when table is empty.
Fixed
- /api/admin/locations PATCH β added inline requireAdmin() auth check. The route was already proxy-gated, but inline check is defense-in-depth and consistent with sibling endpoints.
v2.68.32026-05-06ProductionChanged
- /admin/providers empty state β was 'No providers yet.' Now shows stethoscope icon + explanation that providers need NPI + signature + Doxy URL to issue valid authorizations + a primary 'Add your first provider' CTA that opens the inline form.
v2.68.22026-05-06ProductionChanged
- /admin/patients empty state β was a flat 'No patients yet.' message. Now shows a card with users icon, explanation that patients populate from bookings or imports, and two CTAs: Import patients (Salesforce CSV) and Book one manually. Filter-active empty state (no matches) keeps the simpler 'Clear filters' button.
v2.68.12026-05-06ProductionChanged
- /admin/today empty state β was a flat 'No appointments scheduled.' message; now shows a friendly card with calendar icon, suggestion to follow up on call queue or review intakes, and three quick links (Calendar, Patient list, All appointments). Useful pre-launch and on quiet days.
v2.68.02026-05-06ProductionChanged
- Admin preflight banner β now also surfaces pending DB migrations (17 + 18) and recent integration failures (last 24h). Previously these only showed up if Doug visited /admin/launch; now they appear at the top of every admin page so issues can't sit unnoticed for days.
v2.67.22026-05-06ProductionChanged
- Patient portal: home logo link converted from to Next.js for proper client-side routing.
v2.67.12026-05-06ProductionChanged
- Booking confirmation page (StepConfirmation) β added 'Questions or need to make a change?' contact card with prominent clinic phone number and a callable tel: link. Closes the gap where post-booking patients had no clear way to reach the clinic without leaving the page.
- StepConfirmation share URLs (text + Facebook) now use NEXT_PUBLIC_APP_URL instead of hardcoded flow.greenwellness.org β survives DNS cutover.
v2.67.02026-05-06ProductionAdded
- /admin/launch: 'Quick generate default slots' inline action β appears as an amber callout when slot count is 0 but providers exist. One click creates MonβSat 9amβ5pm Γ 30-min Γ 8-week slots across every active provider (telehealth if doxyMeUrl set, in-person if location assigned). Idempotent.
- POST /api/admin/slots/quick-generate β ADMIN-only. Returns per-provider counts (telehealth + in-person separately). Audits as QUICK_GENERATE_SLOTS.
v2.66.12026-05-06ProductionChanged
- Admin training (Launch Readiness section): updated 'four smoke tests' step to 'five smoke tests' with the booking dry-run added; new 'Preview the scheduled emails on demand' step covering the three new send-to-me buttons (daily briefing, EOD, weekly digest). Includes ADMIN_NOTIFY_EMAIL setup tip.
v2.66.02026-05-06ProductionAdded
- /admin/launch SmokeTestPanel: 'Preview scheduled emails' card β three side-by-side preview buttons for the daily briefing (6am PT), end-of-day rollup (5pm PT), and weekly digest (Mondays 8am PT). Fires the actual cron handler with the proper auth, sends the real email immediately. No need to wait for the schedule.
- POST /api/admin/eod-email/send and /api/admin/weekly-digest/send β ADMIN/MANAGER gated. Both internally invoke the existing cron handlers with a synthetic Bearer-CRON_SECRET request, no logic duplicated.
v2.65.02026-05-06ProductionAdded
- /admin/launch: 'Send the daily briefing now' button β fires the same email the 6am PT cron sends, but to the requesting admin's email immediately. Lets Doug preview yesterday's bookings + revenue + system health without waiting until tomorrow morning.
- POST /api/admin/daily-briefing/send β ADMIN/MANAGER gated, audited as SEND_DAILY_BRIEFING_PREVIEW.
Changed
- Daily briefing computation extracted into src/lib/daily-briefing.ts so the cron + manual trigger share identical logic. The 6am PT cron is now ~30 lines that delegates to the shared lib.
v2.64.02026-05-06ProductionAdded
- Daily briefing email β 'Yesterday at a glance' card: new bookings count + breakdown of new vs returning, gross Stripe revenue, appointments completed, cancellations, new patient signups, total patients on file.
- Daily briefing email β 'System health (last 24h)' card: integration failure count, with link to /admin/launch β Recent failures when >0.
v2.63.02026-05-06ProductionAdded
- Booking write-path smoke test on /admin/launch β creates a synthetic patient + appointment against a real available slot inside a Prisma transaction, then rolls back. Proves the booking write path works end-to-end (DB writes, foreign keys, slot lookup, schema constraints) without persisting any test data.
- POST /api/admin/smoke-test/booking β returns checkpoint trail showing exactly where the dry-run reached. Five smoke tests now total: email, SMS, blob, stripe, booking.
v2.62.32026-05-06ProductionAdded
- /admin/launch: 'Needs attention' shortlist at the top β pulls every non-green row from across all 11 sections into one prioritized list (blockers first, then caveats), with section labels + Fix links. Hidden when everything is green. Saves scrolling through dozens of rows looking for what's broken.
v2.62.22026-05-06ProductionChanged
- Admin training (/admin/training) β added 'Launch Readiness' section with 7 steps covering the cockpit, four smoke tests, pending migrations, session secrets, cron health, integration failures, and the GO_LIVE_CHECKLIST. Also added a 'See who's working right now' step to Settings & Admin documenting the new live activity status + login history view on /admin/users.
v2.62.12026-05-06ProductionAdded
- /admin/launch: 'SEO surface' section β counts sitemap entries (under 100 = caveat, suggests DB read failed during build), checks robots.txt for accidental Disallow:/ block, and confirms canonical URL is set. Catches build-time silent failures that would otherwise destroy SEO at launch.
v2.62.02026-05-06ProductionAdded
- SMS smoke test on /admin/launch β sends a real test SMS via the active provider (RingCentral preferred, Twilio fallback). Auto-detects provider, validates phone number, normalizes to E.164.
- POST /api/admin/smoke-test/sms β ADMIN-only, returns provider used and success/failure with hints (10DLC, trial-account restrictions, suppressed list).
- Run-all aggregate verdict now includes SMS in the count.
v2.61.22026-05-06ProductionAdded
- /admin/launch SmokeTestPanel: 'Run all tests' button β fires email + blob + stripe probes in parallel and shows aggregate verdict banner (X passed / Y caveats / Z blockers). One click to verify pre-launch wiring.
v2.61.12026-05-06ProductionAdded
- /admin/launch: 'Session & internal secrets' section β checks ADMIN/PROVIDER/DISPENSARY session secrets, CRON_SECRET (drives all scheduled jobs), PORTAL_TOKEN_SECRET (patient magic links), SALESFORCE_WEBHOOK_SECRET. Each missing value flagged as blocker with the specific feature it breaks.
v2.61.02026-05-06ProductionAdded
- /admin/launch: 'Database migrations' section β introspects information_schema to detect which prod-migration-N.sql files have been applied vs pending. Pending migrations marked as blockers with the exact node -e command to run them. Catches the 'forgot to apply migration X' footgun before patients see broken features.
v2.60.02026-05-06ProductionAdded
- Stripe smoke test on /admin/launch β calls stripe.balance.retrieve() to verify STRIPE_SECRET_KEY actually works. Reports live/test mode, currency, webhook configuration status, latency, and warnings (test key in prod, missing webhook secret).
- POST /api/admin/smoke-test/stripe β ADMIN-only, returns mode + warnings list. No card charged.
v2.59.02026-05-06ProductionAdded
- Blob storage smoke test on /admin/launch β write+read+delete a tiny test file via @vercel/blob to verify cert PDFs and uploads will work pre-launch. Returns per-step latency.
- Login history view per staff member at /admin/users β clock icon next to each row expands a sub-row showing that user's last 30 ADMIN_LOGIN events with timestamp + IP. ADMIN+MANAGER role gated; audited as VIEW_LOGIN_HISTORY.
- POST /api/admin/smoke-test/blob and GET /api/admin/users/login-history endpoints.
v2.58.02026-05-06ProductionAdded
- Staff activity tracking on /admin/users β new 'Activity' column shows real-time status pill: Active now / Idle Xm / Offline Xh / Offline Xd. Live heartbeat from every admin's browser updates AdminUser.lastSeenAt every 60s while the tab is visible.
- POST /api/admin/heartbeat β admin-gated, debounced server-side to once per 30s per user. Skips writes when document.hidden so backgrounded tabs don't spam.
- AdminUser.lastSeenAt column (prod-migration-18.sql β must be applied to Neon).
- Last login column on /admin/users now shows full date + time (was date only).
Changed
- BOOKKEEPER role allowlist in proxy.ts now includes /api/admin/heartbeat so accounting-only staff also show as Active.
- /api/admin/users falls back to a no-lastSeenAt query if the column doesn't exist yet β safe deploy before prod-migration-18 is applied.
v2.57.02026-05-06ProductionAdded
- /admin/launch: 'Smoke tests' panel β interactive test-email button delivers a real email through the active provider (Postmark / SES / Resend) to verify deliverability before launch. Admin email auto-prefilled from session.
- POST /api/admin/smoke-test/email β ADMIN-only endpoint; returns provider used, success/failure, and SDK error if any. Audits as SMOKE_TEST_EMAIL.
v2.56.12026-05-06ProductionAdded
- /admin/launch: 'Recent failures' section β surfaces integration audit failures (email, SMS, Salesforce, Practice Fusion) from the last 7 days. Recent (<24h) failures = blocker; older = caveat; empty = clean.
v2.56.02026-05-06ProductionAdded
- GO_LIVE_CHECKLIST.md at repo root β explicit 7-day launch plan with day-by-day tasks tagged DOUG / VENDOR / CLAUDE, soft-launch flag reference, risks & contingencies.
- /admin/launch: new 'Cron jobs' section showing last firing time per scheduled cron (reminders, no-show, renewals, slot generation, daily briefing, etc.) with stale detection.
Changed
- /admin/launch footer now points to GO_LIVE_CHECKLIST.md and DNS_CUTOVER.md for full launch context.
v2.55.22026-05-06ProductionChanged
- Conditions detail pages, provider detail pages, and Terms page: replaced custom one-off headers with SiteNav for consistent navigation across all inner pages.
- Conditions detail pages: import FAQS from @/lib/faq-data directly instead of re-exporting through FAQ component.
v2.55.12026-05-06ProductionFixed
- About page: duplicate /privacy link corrected β second link now points to /faq#privacy (Privacy FAQ).
Changed
- Footer: added /pricing link to Services nav section.
v2.55.02026-05-06ProductionAdded
- /pricing page: transparent pricing page using Services component β two-card layout (New $175 / Renewal $140), pricing FAQ, Offer JSON-LD, sitemap entry.
Changed
- /leave-a-review: SiteNav header, dark hero section, Google review promoted as primary CTA with 'Most impactful' badge, other platforms moved to 3-col secondary grid.
v2.54.12026-05-06ProductionFixed
- Patient import: Salesforce split address columns (Mailing Street / City / State / Zip) now auto-combined into a single address string.
- Patient import: smsConsent now mapped from Salesforce boolean export values (TRUE/FALSE, yes/no, 1/0); previously all imported patients defaulted to smsConsent=false regardless of SF data.
- Import page column reference updated to document both fixes and add Salesforce-specific import tip.
v2.54.02026-05-06ProductionChanged
- Article pages (/learn/[slug]): SiteNav header, 2-column desktop layout with sticky sidebar (booking CTA, article meta, related guides), Link tags throughout.
- /faq: SiteNav header, 17 questions reorganized into 5 named categories (Qualifying & Eligibility, Appointments & Process, Pricing & Tax Benefits, After Your Authorization, Privacy & HIPAA) with in-page jump links.
v2.53.12026-05-06ProductionChanged
- Hero guarantee badge: fixed contrast for dark background (white/muted text instead of dark green on dark navy).
- TaxSavings: added 'Card pays back' column showing weeks/months until the $175 card fee is recovered in tax savings β updates live with the slider.
- /learn page: switched to SiteNav (consistent dark header), article list converted to 2-column card grid with category count pills and read/time footer on each card.
- Locations section: added 'View hours, directions & full details' link to /locations page below the clinic grid.
v2.53.02026-05-05ProductionAdded
- Multi-state expansion foundation: state landing pages for Missouri, Virginia, Minnesota, and Maryland at /telehealth/[state] β full qualifying conditions, card registration steps, city grids, state FAQ, and coming-soon waitlist CTAs.
- src/lib/states-content.ts β MMJState data model and content for 4 Tier 1 expansion states.
- src/components/telehealth/StateLandingPage.tsx β reusable state hub page component.
- Sitemap updated: 4 new state telehealth entries at priority 0.85.
- Hero: 'If you don't qualify, you don't pay' guarantee badge surfaced near CTAs; tax savings anchor link in pricing card.
- Reviews section: social proof stat cards (WA-licensed physicians, same-day auth, HIPAA) now show when no Google reviews are configured.
- FAQ added to site nav and footer link fixed from #faq anchor to /faq page.
- GuaranteeBar added to homepage between WhyUs and Physicians sections.
Changed
- HowItWorks: step circles now graduate visually (01 filled green β 02 light green β 03 faint green β 04 neutral); connector lines more prominent.
v2.52.22026-05-05ProductionFixed
- /faq build failure β extracted FAQS array to src/lib/faq-data.ts to fix server/client boundary error (was imported from a 'use client' component).
v2.52.12026-05-05ProductionChanged
- Admin roadmap: updated SEO section to reflect 11 condition pages (was 8), /locations index, /faq, and accurate cityΓcondition count.
v2.52.02026-05-05ProductionAdded
- /faq β standalone FAQ page with all 17 questions, FAQPage + speakable JSON-LD, and breadcrumb schema. Added to sitemap at priority 0.85.
v2.51.02026-05-05ProductionAdded
- Admin Providers: email field β admins can now set a provider's email address from the Providers page. Once set, a 'Send password setup' button appears that emails the provider a link to create their /provider/login password.
v2.50.22026-05-05ProductionChanged
- Provider training: added step explaining email+password login at /provider/login as an alternative to the token URL, with forgot-password self-service.
- Admin training: added step for the /admin/content page (34 resource articles) under Outreach & Marketing.
v2.50.12026-05-05ProductionFixed
- Sitemap: article lastModified dates now use each article's real publishedAt date instead of today, so Googlebot sees accurate freshness signals.
v2.50.02026-05-05ProductionAdded
- /locations β new clinic index page listing all 4 WA locations with rich content, MedicalOrganization JSON-LD, and links to each city page. Added to sitemap at priority 0.9.
v2.49.02026-05-05ProductionAdded
- /llms.txt β machine-readable clinic profile for AI assistants (ChatGPT, Perplexity, Gemini). Auto-lists all 34 articles and 11 qualifying conditions.
v2.48.02026-05-05ProductionChanged
- Patient portal referral card β shows patient's personal referral URL and a one-click copy button. Code auto-created on portal load (no staff action needed).
v2.47.02026-05-05ProductionAdded
- Federal benefits guide: SNAP, HUD housing, SSDI/SSI, Medicaid, VA β what medical marijuana authorization does and doesn't affect. 34 articles total.
v2.46.02026-05-05ProductionAdded
- Medical marijuana and gun ownership article β covers federal conflict, ATF Form 4473, 9th Circuit ruling, and practical guidance for WA patients. 33 articles total.
v2.45.12026-05-05ProductionFixed
- Merged duplicate 'Tax Savings' category into 'Tax Benefits' so both tax articles appear under one heading.
- /learn category order is now deterministic: Getting Started β Qualifying Conditions β Conditions β By Location β Telehealth β Tax Benefits β Renewals β After Your Appointment.
v2.45.02026-05-05ProductionAdded
- Two new articles: driving laws (THC DUI limits, 5 ng/mL standard) and do-i-need-records guide (booking conversion blocker) β bringing /learn to 32 articles.
v2.44.02026-05-05ProductionAdded
- Two new articles: employment rights (WLAD/SSB 5123 protections for WA medical cannabis patients) and seniors guide β bringing /learn to 30 articles.
v2.43.02026-05-05ProductionAdded
- /admin/content β Content Library page showing all 28 articles grouped by category with publish dates, read times, and direct links.
- Content Library added to admin sidebar nav (Data section) and Cmd+K search (keywords: articles, learn, seo, blog).
v2.42.02026-05-05ProductionAdded
- Provider forgot-password flow β /provider/forgot-password and /provider/reset-password pages with email-based token reset.
- Schema: passwordResetToken + passwordResetExpiry fields on Provider model (prod-migration-17.sql).
- Forgot password link on /provider/login page.
v2.41.02026-05-05ProductionAdded
- Six new condition articles: epilepsy, Crohn's disease, glaucoma, Parkinson's disease, HIV/AIDS, ALS β completing all 11 qualifying condition pages.
- All 11 condition pages now show a matching article cross-link card. All 28 articles now show a qualifying condition cross-link card where applicable.
v2.40.02026-05-05ProductionAdded
- Three new condition articles: anxiety, cancer, multiple sclerosis β bringing /learn to 22 articles.
- Condition cross-links on /conditions/anxiety, /conditions/cancer, /conditions/multiple-sclerosis pages now resolve to their matching articles.
- Article-to-condition cross-links on the three new article pages (qualifying condition card below article body).
v2.39.92026-05-05ProductionChanged
- Provider training guide updated β 'After authorization is issued' now mentions the SMS cert notification so providers know patients get both email and text.
v2.39.82026-05-05ProductionAdded
- Lynnwood, WA article at /learn/medical-marijuana-card-lynnwood-washington β local SEO for Snohomish County / Mountlake Terrace / Edmonds / Bothell area. All four Green Wellness clinic cities (Spokane, Lynnwood, Olympia, Vancouver) now have dedicated resource articles.
- Seattle and Lynnwood city articles now show a cross-link card to the Green Wellness Lynnwood clinic location page.
v2.39.72026-05-05ProductionAdded
- SMS cert delivery on authorization approval β when a provider approves a cert, patients with SMS consent now receive an immediate text with their portal link so they can download their authorization right away. smsConsent-gated; SMS failure never blocks cert approval.
Changed
- Admin roadmap page synced with actual built state β Patient Portal, Dispensary Portal, Role-based auth, RingCentral adapter, Infrastructure, and SMS cert delivery now correctly marked done/partial.
v2.39.62026-05-05ProductionAdded
- speakable spec on FAQPage JSON-LD for provider profile, telehealth city, telehealth city+condition, and location city+condition pages β completes speakable coverage across all ~220 FAQ-bearing pages.
v2.39.52026-05-05ProductionAdded
- Article ItemList JSON-LD schema on /learn index β all articles now appear in search result lists.
- speakable spec on FAQPage schema for telehealth, location, and condition pages β voice assistants can read FAQ answers aloud.
- id="faq" anchors on FAQ sections that lacked them (telehealth, location, condition pages).
Changed
- Twitter card type upgraded from summary to summary_large_image β shares now show the full 1200Γ630 OG card.
v2.39.42026-05-05ProductionAdded
- Dynamic OG social-share images on all major pages: Learn index, article pages, Conditions, About, Dispensaries, Providers, Telehealth, and Location pages β consistent 1200Γ630 cards for every shared link.
v2.39.32026-05-05ProductionAdded
- City-specific article pages (Spokane, Olympia, Vancouver) now show a cross-link card to the corresponding clinic location page β similar to the condition cross-links added in v2.38.3.
v2.39.22026-05-05ProductionAdded
- /refer landing page now emits BreadcrumbList JSON-LD.
v2.39.12026-05-05ProductionAdded
- About page now emits BreadcrumbList JSON-LD (Home β About). Previously only had MedicalOrganization schema.
v2.39.02026-05-05ProductionChanged
- Admin training guide: added 'Give a patient their personal referral link' step to the Outreach & Marketing section, and a quick-reference entry for the same.
v2.38.92026-05-05ProductionAdded
- Qualifying Conditions index page now emits BreadcrumbList + MedicalCondition ItemList JSON-LD (one entry per condition). Previously the page had no schema at all.
v2.38.82026-05-05ProductionAdded
- Patient detail page: 'Copy referral link' button below 'Send portal link'. One click creates a $25 promo code for the patient (FIRSTNAME25) and copies the /refer link to clipboard. 409 on existing code is handled gracefully.
v2.38.72026-05-05ProductionAdded
- Patient portal now shows a referral card for patients who have completed at least one appointment β links to /refer and prompts them to call for their personal code.
v2.38.62026-05-05ProductionAdded
- Providers page now emits a Physician ItemList JSON-LD block (one entry per active provider from DB) β gives Google richer signal for each physician.
- Sitemap now includes /refer and /changelog pages.
v2.38.52026-05-05ProductionAdded
- Dispensary directory page now emits ItemList + BreadcrumbList JSON-LD schema (33 Washington State dispensaries as LocalBusiness entries). Improves Google rich-result eligibility for the directory.
Changed
- Dispensary data moved from inline client component to src/lib/dispensaries.ts so both the Directory UI and the server-side JSON-LD can share one source.
v2.38.42026-05-05ProductionAdded
- /refer landing page β patients with a referral code can enter it and be redirected to their discounted booking page; patients without a code get a call-us CTA.
v2.38.32026-05-05ProductionAdded
- Article pages now show a visible published date next to the read-time and author line (e.g. 'Published April 1, 2026') using parseISO for timezone-safe display.
- PTSD and Chronic Pain article pages now include a cross-link card pointing to their respective qualifying-condition pages (/conditions/ptsd, /conditions/chronic-pain).
v2.38.22026-05-05ProductionAdded
- /resources now redirects to /learn (permanent). Any external links using /resources will resolve correctly.
- /learn index page now emits BreadcrumbList JSON-LD (Home β Patient Resources). Matches the breadcrumb schema already added to individual article pages in v2.38.0.
v2.38.12026-05-05ProductionAdded
- Resources link added to main site nav β visitors on any page can now find the /learn article library without navigating back to the homepage footer.
- Condition pages for Chronic Pain and PTSD now show a related Patient Guide card linking to the dedicated /learn article for that condition. Improves internal linking and gives patients a clear path to more detailed information.
v2.38.02026-05-05ProductionAdded
- SEO: all 18 /learn articles now carry datePublished + dateModified in their Article JSON-LD β Google can assess content freshness instead of treating them as undated. Dates spread across AprilβMay 2026, reflecting when articles were authored.
- SEO: BreadcrumbList JSON-LD emitted on every /learn/[slug] page β three-level path (Home β Resources β Article title). Activates Google breadcrumb rich results.
- SEO: speakable SpeakableSpecification added to the FAQPage JSON-LD on the homepage (#faq selector) β marks the FAQ block as eligible for Google Assistant / voice search audio readout.
v2.37.12026-05-05ProductionFixed
- /api/provider/feedback (added v2.34.0) had no rate limit. Per-provider 20-per-hour cap added β that's generous enough to never trip a real provider on a busy day but tight enough that a leaked portal token can't be used to flood the admin feedback queue. Returns 429 with a clear "contact admin directly" hint if exceeded
v2.37.02026-05-04ProductionAdded
- HIPAA audit-log coverage filled in on five staff-initiated PHI-touching write endpoints that previously skipped audit() entirely. New AuditAction entries β UPDATE_APPOINTMENT_NOTES, SEND_APPOINTMENT_REMINDER, SEND_RENEWAL_REMINDER, BULK_SEND_RENEWAL_REMINDERS, RESEND_BOOKING_CONFIRMATION β now write a row on every change with the staff user ID + IP. Detail field captures email/sms send status without the message text (would leak PHI into the audit row). Closes the gap where you couldn't tell from the audit log which staff member changed an appointment's clinical notes or sent which patient a renewal reminder. Routes audited: /api/admin/appointments/notes, /api/admin/appointments/[id]/remind, /api/admin/appointments/resend-confirmation, /api/admin/patients/remind, /api/admin/patients/bulk-remind
v2.36.02026-05-04ProductionFixed
- "Send reminder" button on the appointment detail page had the same lying-UI bug v2.30 fixed for the portal-link button: when no email or SMS vendor is configured, sendEmail()/sendSms() return false but the API returned 200, so the button always said "Reminder sent." Now it parses the actual { emailSent, smsSent } from the API response and shows truth: "Sent via email + SMS", "Sent via email", "Sent via SMS", or β when neither succeeded β "Not delivered β vendor not configured" with an amber warning style. Same fix the audit caught for portal-link, applied here. Also dropped a leftover client-side console.log line that was just noise in the browser DevTools
v2.35.32026-05-04ProductionFixed
- Stale internal references to the now-redirected /hipaa-notice path. The 308 redirect from v2.35.2 made them all functional, but four internal links (booking wizard consent, /about, /terms body text + footer, /admin/roadmap) still pointed at the old URL. Updated to /privacy directly. The terms page also had a confusing dual mention ("See Privacy Notice at /privacy and HIPAA Notice at /hipaa-notice") implying they were two separate documents β now reads as one document at /privacy
v2.35.22026-05-04ProductionFixed
- Consolidated two duplicate Notice of Privacy Practices pages β /privacy (180 lines, linked from footer) and /hipaa-notice (109 lines, linked from booking wizard's HIPAA consent checkbox) were both claiming to be the canonical privacy notice with different content. Compliance drift waiting to happen. Removed the /hipaa-notice page; added a 301 redirect /hipaa-notice β /privacy in next.config.ts so the existing wizard consent link still works without fixing every reference. /privacy is now the single source of truth
v2.35.12026-05-04ProductionFixed
- /dispensaries page (added v2.24.1) was missing from sitemap.ts β Google never knew it existed. Added at priority 0.7. Small SEO loss closed
v2.35.02026-05-04ProductionAdded
- /admin/launch β single-page launch-readiness cockpit. Six categorized sections (Site & deploy Β· Providers Β· Locations Β· Feature flags Β· Integrations Β· Operations today) with per-row π’/π‘/π΄ status. Big GO/NOT-READY/SOFT-LAUNCH banner at the top derived from the worst severity across all rows. Each row has actionable detail ("Missing: NPI Β· Doxy URL Β· photo") and a deep-link to the relevant fix-it page. Includes auto-detected stuff the PreflightWarnings banner doesn't (Stripe live keys + webhook, email vendor in use, SMS vendor, Blob token, soft-launch flag positions, slot inventory, last admin action today, open feedback queue). Sidebar entry under Operations group + Cmd+K ("preflight", "checklist", "cutover" all match). Built as Doug's go/no-go dashboard for Sunday-morning DNS cutover β refresh once everything's green and flip the DNS
v2.34.02026-05-04ProductionAdded
- QA report E1 β Provider feedback system. Providers can now report issues from their portal ("See something off?" panel below their signature card) β pick a category (missing records Β· incomplete intake Β· wrong patient info Β· scheduling issue Β· technical issue Β· other), write a few sentences, send. The report lands at /admin/feedback as a new admin queue: Open and Resolved tabs, contextual links to the patient and appointment if attached, mark-resolved button with optional resolution note. Admin nav surfaces an amber-badge unresolved count next to the Provider feedback link, polled the same way Messages and Waitlist counts are. New AuditLog entry RESOLVE_PROVIDER_FEEDBACK on each resolution. New schema: ProviderFeedback model with FeedbackCategory + FeedbackStatus enums (prod-migration-16.sql, additive-only β already applied to prod). Closes the QA reviewer's loop: providers had no way to flag missing data without phoning admin
Changed
- Schema: 21 models now in the Prisma schema. audit-schema check still clean against prod
v2.33.02026-05-04ProductionAdded
- QA report F1 β /admin/import now has a "Download template CSV" button (top-right) that gives staff a starter file with the canonical column headers and two example rows. No more "what do I export from Salesforce / what columns work?" guessing. Required vs optional fields are now visually marked: red dot next to the only required field (Email), grey dot next to the 10 optional fields. New footer note explicitly states what's required, what date formats are accepted (YYYY-MM-DD, M/D/YYYY, etc.), and that blank columns don't overwrite existing patient data on update β which was the most-asked-and-undocumented behavior
v2.32.02026-05-04ProductionAdded
- QA report D3 β Promo code performance section on /admin/reports. Per-code table showing: code + label, discount amount, uses (real redemptions from appointments + a tooltip showing PromoCode.usedCount when they differ β usually because of cancellations), cap, redeem-percent (usesΓ·cap), total dollar amount given out (summed from appointment.discountCents on non-cancelled bookings), and a status badge (Active / Exhausted / Expired / Disabled). 6-month roll-up at the bottom shows total redemptions and total dollars discounted across the whole catalog. Direct link to /admin/promo-codes for editing. Closes the QA gap where promo codes were managed but not measured: Doug now sees "BLACKFRIDAY redeemed 18 times, $1,800 given out" alongside revenue and provider performance
v2.31.02026-05-04ProductionAdded
- QA report D1 β EOD report now includes a "Communications & calls" panel showing today's volume across 6 channels: outbound calls placed (WorkflowEvent OUTREACH_CALL + PatientMessage CALL out), inbound calls connected (PatientMessage CALL in with non-null durationSec β already-counted voicemails stay on the top tile), SMS sent and received (PatientMessage SMS), and emails sent (WorkflowEvent EMAIL) and received (PatientMessage EMAIL via Postmark inbound). Panel only renders when at least one channel has activity for the day. The voicemail tile up top remains unchanged. Closes the gap the QA reviewer flagged: the EOD report previously rolled up productivity audit-log actions but didn't surface raw communication volume
v2.30.02026-05-04ProductionFixed
- QA report B5 β "Send portal link" silently failing in soft-launch mode. Without an email vendor configured, sendEmail() returned false but the API still returned 200, so the button showed "Link sent" while no email actually went out. Now: API returns the link in the JSON response regardless of email outcome. The button detects sent=false, shows the link in a copyable input + a Copy button, and explains "Email not sent (no vendor configured) β copy and text the patient manually." Staff can deliver the link via SMS or read it on a call without the patient ever knowing the email vendor was down
- QA report B3 β calendar showed past dates as visible-but-disabled (greyed-out cells the patient might try to click). Past days in the current month now render as fully empty cells that hold their grid slot but show no number/dot, so only future days look interactive
Changed
- QA report C1 ("VERY IMPORTANT") β Quick Log on the patient profile is now a Workflow Checklist instead of a fire-and-count button row. Each of the 6 actions (consent uploaded Β· records uploaded Β· records reviewed Β· followed up Β· encrypted email sent Β· telemed offered) now has a checkbox state. Items already done in the last 30 days for this patient render with a green β on page load (server-rendered from AuditLog so no flicker). A progress bar at the top shows X/6 done at a glance. Re-clicking a completed item still logs another row (productivity counts unchanged), but the visual emphasis is now "what's left" instead of "how many times did I click this"
v2.29.02026-05-04ProductionChanged
- All inner public pages (/conditions, /about, /providers, /telehealth) now render the shared SiteNav component instead of their own one-off
elements. QA report flagged that the header disappeared / changed shape on inner pages β same nav links, same brand bar, same My Portal + Get My Card CTAs are now visible everywhere. SiteNav's onOpenScheduler prop is now optional: when omitted (inner pages, where the booking wizard isn't mounted), clicking Get My Card navigates to /?book=true&type=new and the homepage's BookingParamHandler picks up the param and opens the wizard automatically. Net effect: book-from-anywhere now works without each page needing to re-implement the wizard plumbing
Added
- Free-text "describe your condition" field on Step 1 of the booking wizard β appears only when the patient picks "Something else" from the qualifying-conditions list. 500-char limit, character counter, required-when-other-is-selected. Replaces the previous black-box where staff had to phone the patient to ask what they meant. The provider sees this on the appointment detail page before the visit
- In-person appointment disclaimer on Step 3 of the wizard β when the patient picks In-Person, an amber callout reads "Note: appointments are subject to final confirmation after our team reviews any medical records you upload during intake. We'll call you within 24 hours if anything needs to change." QA flagged that in-person bookings had no clarity that records review can change the appointment
v2.28.02026-05-03ProductionAdded
- Pre-launch readiness banner across /admin/* β surfaces seed-data gaps that would degrade the patient experience if we cut DNS over today. Red severity for hard blockers (provider missing NPI = invalid WA DOH PDF, provider missing Doxy URL = broken telehealth video link). Amber severity for soft issues (provider missing email = no portal recovery, provider missing photo = hidden from public providers section, location missing hours = blank hours field on the public site). Each line includes a deep link to the admin page where staff can fix it ("Fix in providers β" / "Fix in locations β"). Self-disappears once everything's clean. Shown on every admin page so it can't be missed; hidden from BOOKKEEPER role since they don't operate clinic surfaces
v2.27.12026-05-03ProductionChanged
- Updated /admin/training to cover the soft-launch operational pattern that v2.22β2.27 introduced: the Daily Workflow now leads with the "Recent bookings β needs confirmation" callback queue on the Dashboard (the morning worklist), and ends with a Cmd+K palette tip. Brand-new "Soft-launch Mode" section walks staff through the amber banner, working the callback queue, collecting payment from "Owes payment" deferred bookings, what to say when a patient asks why they didn't get an email, and how the mode flips off when vendor BAAs sign β all in plain language
- Updated /provider/training Getting Access section with two new one-time setup steps: completing the Provider profile card (NPI, email, Doxy.me URL, photo) and uploading the signature image. Both block authorization issuance until set, so they're framed as required-before-first-visit rather than nice-to-haves
v2.27.02026-05-03ProductionAdded
- "Recent bookings β needs confirmation" card on /admin dashboard. Lists the last 8 SCHEDULED appointments booked in the last 7 days with patient name, click-to-call phone link, appointment time, type (telehealth/location), and how long ago they booked. Tagged "New" for first-time patients and "Owes payment" for deferred bookings (notes contains [DEFERRED PAYMENT]). One click on the row opens the appointment detail page where staff can mark it confirmed. Designed for the soft-launch operational pattern: every morning staff opens admin, sees the callback queue at a glance, picks up the phone
v2.26.02026-05-03ProductionAdded
- Deferred-payment soft-launch mode β when PAYMENT_DEFERRED=true is set in Vercel (server) AND NEXT_PUBLIC_PAYMENT_DEFERRED=true is set (client), the booking wizard's payment step replaces the Stripe form entirely with a "Confirm appointment" panel: address field, total-due-at-visit summary, and a single Confirm button. The booking is created in the DB with stripePaymentId=null and a [DEFERRED PAYMENT] note in the appointment.notes field so staff can see at a glance which bookings still owe money. Server still requires the env flag to allow no-Stripe bookings (clients can't sneak past it). With this and the existing MANUAL_CALLBACK_MODE flag, the practice can go live on greenwellness.org without a payment processor or email vendor wired up β staff calls every booking within 24 hours and collects payment via Poynt POS at or before the visit. Two env-var flips later (when Stripe live keys land and when Postmark BAA signs), the site becomes fully automated without a code change
Fixed
- Slot-taken refund path now no-ops cleanly when there's no payment to refund (deferred bookings)
v2.25.02026-05-03ProductionAdded
- Provider self-onboarding profile card on /provider/[token] β providers can now fill in their own NPI, email, Doxy.me waiting-room URL, and headshot photo themselves instead of Doug having to collect every field by phone and enter them in admin. Card warns if any required field is missing ("Required before you can issue authorizations: NPI, Email, Doxy.me link, Photo") and turns green when complete. NPI validated as 10 digits, Doxy URL validated as on doxy.me, email validated, photo capped at 4 MB. Photos store in public Vercel Blob (they're displayed on the public provider pages); the existing signature card still uses private Blob
- Soft-launch banner on /admin/* pages β when NEXT_PUBLIC_MANUAL_CALLBACK_MODE=true is set in Vercel, every admin page shows a persistent amber banner: "Manual callback mode Β· Email/SMS automation paused until vendor BAA signs Β· staff calls every new booking within 24h" plus a live count of bookings created in the last 24h. Hidden from BOOKKEEPER role since they don't have PHI access. Removes the risk of staff forgetting they're in soft-launch mode and assuming patients got an automated confirmation
v2.24.12026-05-03ProductionAdded
- Public /dispensaries page β gives the 33-store partner directory a permanent home now that it's no longer crowding the homepage. Same Directory component (search, region/county filters, AβZ view), wrapped in a page with its own SEO metadata. Linked from SiteNav ("Dispensaries") and the footer's Learn column. Patients showing up post-launch with "where do I redeem my card" land on a focused page instead of having to scroll the homepage. Also opens up future SEO for queries like "WA medical dispensary directory" without diluting greenwellness.org's homepage authority
v2.24.02026-05-03ProductionChanged
- Homepage redesign β went from 11 generic-medical-SaaS sections to a tighter 9-section flow that answers "what does it cost" and "can I walk in tomorrow" in 5 seconds. New hero is two-column with the headline on the left ("Your Washington medical cannabis card, issued the same day") and a $175 / $140 pricing card on the right that doubles as a booking CTA β pricing was previously buried three sections deep. The refund-if-you-don't-qualify promise is surfaced into the subhead instead of being buried in the payment step. Phone number is a real button next to the primary CTA, not a footnote
- TrustBar reskinned β was a noisy 6-pill horizontal scroll on dark blue chrome. Now 4 quiet credibility signals on white: WA-licensed, HIPAA-protected, 4 locations, telehealth statewide. Reduces visual noise between hero and HowItWorks
- HowItWorks copy fixed β step 1 used to say "Check if you qualify in 30 seconds" which was a bait-and-switch since the wizard takes ~11 minutes. New step 1 is "Book your visit β Online, ~5 minutes". Step 2 mentions "records help but aren't required" (compliance-friendly). Step 3 explicitly says "if your physician determines medical cannabis is appropriate" β no efficacy promise
- WhyUs reframed β dropped the trash-talk-the-competition tone and the puffery ("Washington's trusted full-service evaluation practice"). Right column is now a "Our promise" + "What you'll always get" pair anchored on the refund language and the "you decide, not a sales script" honest framing
- Section ordering β TaxSavings moved up (most concrete answer to "why bother getting a card"); Reviews moved down near FAQ where the "be the first to review" CTA fits a footer position better than a hero-adjacent one
- SiteNav cleaned β removed the dead /#services anchor (Services section was folded into the hero card). Footer's Services column updated to match
Removed
- Services section (folded into hero pricing card)
- GuaranteeBar (its 3 promises now live on the hero card)
- Directory section (33-row partner-dispensary list β way too long for a homepage; will move to a dedicated /dispensaries page in a follow-up). Files left on disk so they can be repurposed
Added
- ClosingCTA β every commercial site has a bottom-of-page "book now" bookend; this one didn't. Echoes the hero pricing in dark navy, with the WA-licensed/HIPAA-compliant microcopy
- Booking wizard 409 "slot taken" auto-refresh β when the patient gets bounced back from Step 5 because someone else booked the same slot in the few seconds between time-pick and payment-submit, the calendar now lands them on the SAME date they had picked (instead of resetting), refetches availability with cache:no-store so they see the now-taken slot has actually disappeared, and shows the error banner up top. Implementation: lastSelectedDate is preserved on WizardData across the bounce, plus a refreshNonce that the wizard bumps on 409. Closes the loop on the most-likely error case in the booking funnel
- Admin command palette (Cmd+K / Ctrl+K) at /admin/* β fuzzy-search across all 28 admin pages and live patient lookup (name / email / phone) via /api/admin/patients/search. Keyboard-driven (ββ to navigate, β΅ to open, Esc to close), focus-trapped, returns focus to the trigger on close. Hidden for BOOKKEEPER role since they don't have PHI access. Drops admin navigation from "3 clicks through the sidebar" to "βK, type, enter" for the find-a-patient and jump-to-page workflows that staff hit dozens of times per day
v2.23.02026-05-03ProductionChanged
- Sticky mobile booking bar now triggers off the hero scrolling out of view via IntersectionObserver, instead of a hardcoded 600px scroll threshold. On 375Γ667 phones (iPhone SE etc) the hero ends past 720px, which meant the sticky CTA used to pop in mid-hero and double-stack with the hero's own button. With the IO observer it appears at exactly the moment the hero leaves the viewport, regardless of viewport height. Internal pages without a #hero element fall back to a small 200px scroll trigger so the bar still appears after some scrolling
- Hero trust point "100% confidential" replaced with "Refund if you don't qualify" β addresses the actual moment of friction at the payment step (refund language exists in StepPayment but was buried below the fold). HIPAA / confidentiality is not a true differentiator (all medical practices have it); the refund guarantee is
- /my-appointments tab labels rewritten for older patients. "Email link" β "Email me a link" and "Password" β "Sign in with password" β the original jargon read as confusing for the audience least likely to use the magic-link flow
- Telehealth tile on Step 3 of the booking wizard now renders in a disabled state for new patients (with helper copy "Renewals only β book in-person below") instead of being hidden entirely. Hiding the tile and silently auto-selecting in-person was jarring; showing it disabled explains the constraint without adding a click
- Billing address moved from Step 2 (About You) to Step 5 (Payment). Previously asked for full street address before the user saw a calendar with availability β heavy PII collection ahead of commitment. Now collected at the moment of payment, where the user has already chosen a slot and is mentally committing. Step 2 drops one required field; Step 5 gates the Stripe form behind the address being filled in. Reduces drop-off at Step 2
- Provider photos converted from raw
to
from next/image on three surfaces: home Physicians section, /providers index, and /providers/[slug]. Adds lazy-loading + CLS protection + automatic responsive sizing. The hero image on the provider detail page is marked priority so it doesn't push LCP. next.config.ts gains images.remotePatterns for *.public.blob.vercel-storage.com so admin-uploaded headshots actually optimize
v2.22.02026-05-02ProductionFixed
- Pulled fictional patient testimonials from the homepage Reviews section. The previous five "Verified patient reviews" used full names, cities, AND named medical conditions (PTSD, Crohn's disease, MS) β FTC false-advertising risk on its own, considerably worse on a HIPAA-covered medical site since it pairs invented people with invented diagnoses. Reviews section now renders an honest "Be one of our first patients" CTA pointing at /leave-a-review, and only shows an aggregate rating when NEXT_PUBLIC_REVIEW_COUNT > 0 and NEXT_PUBLIC_REVIEW_RATING is set (matching what the JSON-LD structured data already gated on)
- Hero social-proof bar no longer fabricates stats on a fresh practice. Previously /api/public/stats returned a hardcoded 97% authorization rate as a fallback when the DB had zero completed appointments β which is exactly the state we're in at launch β so the homepage was publishing a fabricated quality metric. Stats route now returns approvalRate=null until at least 25 appointments have concluded; Hero only renders the bar when there's at least one real metric (rating from env, approval rate from real sample, or patient count >= 100). Same content, gated on real data
- Hero "Takes 30 seconds" subcopy replaced with "Pre-qualify in 30 seconds". Step 1 of the wizard is the 30-second pre-qualification; the full booking is ~11 minutes. The original copy was a bait-and-switch that surfaced as drop-off at step 2
- Booking wizard is now keyboard- and screen-reader-accessible. Modal had no role=dialog, no aria-modal, no focus trap, no Esc-to-close, and no return-focus on close β meaning the entire patient booking funnel was unusable for keyboard-only and screen-reader users. Added all four: dialog roles + aria-labelledby on a visually-hidden h2, Tab cycles within the modal, Esc closes, focus returns to the trigger element after close, body scroll locks while open. WCAG 2.1 AA conformance for the booking flow
- Form input minimum target size bumped from 32px (h-8) to 40px (h-10) across all inputs. WCAG 2.5.5 calls for 44Γ44 minimum on touch interfaces; 32px also failed the 50+ patient demographic on phones. py increased from 1 to 2 to keep visual centering correct, px from 2.5 to 3 for breathing room
- Footer copy "Protected under HIPAA regulations" replaced with "HIPAA Notice of Privacy Practices" linked to /privacy. Original phrasing was factually wrong (HIPAA protects PHI, not copyright) and missed the actual obligation, which is to surface the Notice of Privacy Practices at the bottom of every page
- Provider cards on the homepage Physicians section now hide if no photoUrl is set, instead of rendering the generic stethoscope icon fallback. Patients are deciding to spend $175 β they want to see the doctor's face. With no photo, the card is more harmful than helpful. Section returns null entirely when zero providers have photos (matching today's seed state)
Added
- Public health endpoint at /api/health β returns { ok, version, sha, env, db, latencyMs, timestamp }. Status 503 if DB query fails, 200 otherwise. Cache-Control: no-store. Used by the post-deploy verification step in the operating-principles cycle (curl + sha-match) and as the smoke-test target for the DNS cutover runbook
- Manual-callback soft-launch mode on the booking confirmation page. Set NEXT_PUBLIC_MANUAL_CALLBACK_MODE=true in Vercel and the patient sees "A staff member will call
within 24 hours" instead of "A confirmation has been sent to " β closes the gap between launch and the eventual Postmark/SES BAA without the site lying about an email that never lands. Telehealth "check your email for the video link" item also adapts. Flip to false (or unset) once an email vendor BAA is signed and POSTMARK_API_KEY/AWS_SES_REGION is configured - DNS_CUTOVER.md β step-by-step runbook for flipping greenwellness.org from old WordPress (Sucuri WAF) to the Vercel app. Pre-flight checklist, GoDaddy DNS edits, Vercel custom-domain setup, SSL cert wait, smoke tests, and a same-day rollback path if the cutover fails
- ROLLBACK.md β runbook for rolling a broken prod deploy back to the previous green deploy via Vercel "Promote to Production". Includes /api/health smoke-test pattern, common-cause checklist for the broken deploy, and explicit guidance on when NOT to roll back (small bugs, slow-but-functional, single-cron failures)
- LAUNCH.md β single-source-of-truth launch tracker. Inventories what's verified ready in prod (DB, secrets, code), what's blocked on Doug decisions or vendor BAAs, and what's queued for Claude. Updated against live env state, not code reads β the v2.21.0 P0 list in TODO.md was partially stale
v2.21.02026-05-01ProductionAdded
- Accounting portal for the bookkeeper at /admin/accounting β per-provider monthly payout summary scoped to fully-completed visits only. A visit counts toward payout when status=COMPLETED, approvedAt is set, AND certPdfUrl exists (i.e. the provider signed the chart and the authorization PDF was generated). Visits marked completed but missing the signed PDF appear separately as "Incomplete" so the bookkeeper can flag them with the provider before paying. Date-range presets for this month / last month plus a custom from/to picker. Per-provider drill-down at /admin/accounting/[providerId] shows every visit in the period with a Signed/Incomplete badge. CSV export at /api/admin/accounting/export so the bookkeeper can drop the payout sheet into their accounting software
- New BOOKKEEPER admin role β sees only the accounting portal. No PHI access (no patients, no charts, no messages, no scheduling). Bookkeepers signing in get redirected from /admin to /admin/accounting; proxy.ts blocks any other /admin or /api/admin path with a 403 / redirect so they can't sidestep the nav. Existing roles (ADMIN, MANAGER, SCHEDULER) unchanged
- Per-provider pay rate (Provider.payPerVisitCents) β admin/manager can set the per-visit dollar amount inline on /admin/accounting (auto-saves on blur, audit-logged via UPDATE_PROVIDER_PAY_RATE). Bookkeepers can read but not edit, so a bookkeeper can't unilaterally raise rates without escalation
- Audit actions VIEW_ACCOUNTING, EXPORT_ACCOUNTING, UPDATE_PROVIDER_PAY_RATE β every accounting view/export and every pay-rate change is logged to AuditLog with the staff user, IP, and detail
Changed
- Schema: AdminRole enum now includes BOOKKEEPER. Provider gains payPerVisitCents Int @default(0). Migration in prod-migration-15.sql β must run before deploy
v2.20.12026-05-01ProductionFixed
- Dispensary portal β cert print queue was showing raw condition IDs (e.g. "chronic_pain, ptsd") instead of human labels because /api/dispensary/certs returned the unmapped Appointment.conditions array. Now the API maps via conditionLabel() so dispensary staff see "Chronic pain Β· PTSD / Anxiety" matching what admin and intake show
- Dispensary portal β BAA-required and load-error states both said "contact support" / "contact Green Wellness support" without an actual phone number. Both now include the {PHONE} tel: link so dispensary staff can reach support in one tap, completing the phone-fallback coverage across all three portals (patient, provider, dispensary)
v2.20.02026-05-01ProductionFixed
- Provider portal β single-sign action ("Issue Authorization") and the bulk-sign button ("Sign & Issue") were two different verbs for the same operation. Unified everywhere to "Sign & Issue" so providers see one consistent action label across the queue, the confirmation dialog, and the training docs
- Provider portal safety: single-sign now enforces the same signature pre-flight check the bulk path already had. /api/provider/action returns 400 with "No signature on file" if the provider tries to issue an authorization before uploading their signature image β previously this path could quietly produce an unsigned cert PDF. Action buttons (Sign & Issue + Bulk sign) are now disabled in the UI when no signature exists, with a tooltip pointing back to the Signature card
- Provider portal safety: single-sign now refuses to issue an authorization for a CANCELLED or NO_SHOW appointment (returns 409). The UI already hid the buttons in the final-state branch, but the API was the one missing the guard if a stale page POSTed
- Provider portal β Sign & Issue confirmation dialog now shows patient age and qualifying conditions inline ("John Doe Β· 42 y/o" / "Chronic pain Β· PTSD"), matching what the bulk-sign modal already showed. Stops the "is this the right John Doe?" risk in a busy queue
- Provider portal β bulk-sign modal state (selected patients, results list, error text) now resets when the modal closes, instead of persisting and surfacing stale results the next time the modal opens
- Provider portal β SignatureCard copy updated. Was "Bulk sign needs a signature" (no longer accurate now that single-sign also requires it) β now reads "Required before signing β upload a clean PNG/JPEG/WebP under 2 MB to enable Sign & Issue". The on-file copy also says "authorization PDF" instead of internal "cert PDF" jargon
v2.19.22026-05-01ProductionFixed
- Patient flow round 4 β JoinVisitCard "Video link not yet available" state and the "join window has ended" state both used to say "call the front desk" without an actual phone number, forcing patients to find the number elsewhere on the page or in their email. Both now include an inline tel: link to {PHONE}
- ChatWidget error state "Please try again or call us directly" was missing the phone number itself. Now reads "call {PHONE}" with an inline tel: link, matching the phone-fallback pattern used everywhere else in the patient flow
v2.19.12026-05-01ProductionFixed
- Stale 1-hour TTL copy across the magic-link surface β /my-appointments login form ("The link expires in 1 hour"), /my-appointments/[token] landing ("This link expires after 1 hour"), and the portalMagicLinkEmail body all now say 15 minutes, matching the actual TTL since the v2.10 security update. Patients were being told a stale window 4Γ longer than reality and may have been confused when the link expired earlier than promised. Patient password reset emails are unchanged β those are still legitimately 1 hour
- Patient terminology β booking wizard Step 2 dispensary-consent footnote said "verify your cert for faster check-in". "Cert" is internal jargon β patients see this as their state-issued authorization. Now reads "verify your authorization". One word change, but reduces the cert/auth/card vs evaluation/visit confusion at a high-leverage moment in the wizard
- Patient flow round 3 β /cancel/[token] "Already cancelled" branch was the only token page without a phone fallback. Added "Need help? Call {PHONE}" so the dead-end coverage across cancel/confirm/checkin/intake/reschedule is now uniform
v2.19.02026-05-01ProductionAdded
- Cert PDF auto-attached to the post-visit email β when a HIPAA-compliant email provider is active (Postmark with BAA, or AWS SES), every approval (admin approve, provider single approve, provider bulk approve) now sends the patient their authorization PDF as an email attachment. Email body switches to "π Your authorization PDF is attached to this email." Resend (no BAA) stays body-only with the portal link β never ships PHI through a non-BAA vendor. Centralized in src/lib/cert-email.ts so all three approval paths share one gating + helper
- Provider self-service signature upload β new SignatureCard at the top of /provider/[token] shows whether a signature is on file and lets providers upload/replace their own (PNG/JPEG/WebP, 2 MB cap). Bulk sign refuses without a signature with a clear message, and the card explains what's needed
- /api/provider/signature β portalToken-authenticated GET (returns hasSignature boolean only) + POST (multipart upload, validates MIME + size, persists to private Vercel Blob)
Fixed
- Post-visit email DOH URL β corrected from app.wacomm.doh.wa.gov to mmjr.doh.wa.gov to match the rest of the site
v2.18.12026-05-01ProductionFixed
- Patient flow round 2 β /reschedule/[token] no-longer-reschedulable state was a soft dead-end ("This appointment can no longer be rescheduled" + a small "Book a new appointment" link). Now shows a primary "Book a new appointment" button plus a "Need help? Call {PHONE}" tel: link so a stuck patient can reach a human in one tap
- Patient flow β /checkin/[token] for telehealth previously dead-ended when the per-appointment videoLink was unset (showed only "Your video link will be sent before the appointment"). Now points patients straight to /visit/[token] where the provider's Doxy.me waiting room URL is resolved automatically, plus a phone fallback if joining fails
- Patient flow β /checkin/[token] in-person too-early state (>2h before appointment) now includes a "See what to bring β" link to /visit/[token] so patients waiting in the parking lot can review the prep checklist instead of just bouncing off the warning
- Booking wizard StepConfirmation β intake CTA button text "Start intake form" β "Complete intake form" for parallel structure with the ConfirmButton CTA shipped in 2.13.1. Same verb, same shape across the flow
v2.18.02026-05-01ProductionAdded
- Bulk approve for the provider portal β "Bulk signβ¦" button at the top of the Awaiting Your Signature queue opens a modal with a checklist of every pending visit (patient name, age, conditions, NEW pill). Provider picks up to 10, clicks Sign & Issue, and a single round-trip generates each cert PDF, persists Blob + DB updates, and emails the patient. Per-row results show inline; partial failures don't block the rest. Provider must have a signature on file (otherwise the route refuses with a clear message). New /api/provider/bulk-approve route, capped at 10 per batch to stay under Vercel timeouts
- Photo-IDs strip on /admin/today β when patients upload images during intake, their photo ID thumbnails appear in a horizontal scrollable strip at the top of the today view. Click to view full-size in a new tab. Patient first name + last initial + appointment time labels each thumbnail. Front-desk staff can verify ID at the door without digging into each appointment record. /api/admin/today now returns idPhotos[] (image-only docs) per appointment
v2.17.02026-05-01ProductionAdded
- Patient intake document uploads β new "Documents" section in /intake/[token] lets patients upload a photo of their WA ID + prior medical records during intake. Files upload immediately on pick (independent of the form submit so they survive an abandoned/resumed intake), saved to private Vercel Blob, and persisted as MedicalDocument rows tied to the appointment. Patient can see/remove their uploaded files; staff and providers see them on the chart. PDF/JPG/PNG/HEIC/WebP, 10 MB each. Backed by /api/intake/[token]/documents (GET/POST/DELETE)
- Provider cert PDF preview β providers can click Preview before signing to see the rendered authorization (with their signature embedded) without persisting it or changing appointment status. New /api/provider/cert-preview/[appointmentId] route, gated by provider portalToken + providerId match. The Sign & Issue button is still right next to it when they're ready
- Patient documents visible to provider β provider portal pending-approval and today rows now render a "Patient documents" chip block listing every patient-uploaded attachment (intake uploads + promoted email attachments) with a one-tap inline preview link. New /api/provider/documents/[id] proxy gates by portalToken + appointment-provider ownership. Providers no longer have to ask staff for the photo of ID β they see it right there
Changed
- Provider Sign & Issue button β relabeled from "Issue Authorization" so providers understand they're signing a legal document, not just clicking a status. The amber color stays as the urgency cue
v2.16.02026-05-01ProductionAdded
- Outbound email attachments β admin can attach files when emailing a patient from the Communication panel. New paperclip Attach button next to the body input, file chips with remove buttons, same allowlist + 10MB/25MB caps as inbound. Send route now accepts multipart/form-data; attachments go to Postmark for delivery and into private Vercel Blob with MessageAttachment rows so they thread alongside the outbound message
- Promote-to-medical-records β one-click button on each inbound attachment chip in the Communication panel saves the file as a MedicalDocument tied to the patient's most recent COMPLETED appointment (or a specified one). Dedupes by blobUrl so re-clicks are safe; checkmark replaces the icon when saved. Audit-logged as UPDATE_PATIENT with the file name
- Pre-visit check-in form β new lightweight 1-minute questionnaire at /previsit/[token] for returning patients. Three yes/no questions (new symptoms, med concerns, dose change) with optional follow-up details, plus a free-text questions-for-provider field. Surfaces in patient portal as a green-tinted CTA card when intake is done, pre-visit not yet submitted, and visit is within 48 hours. Provider portal renders submitted answers as colored YES/no chips with detail text right above the IntakeBlock so they scan it before the call. Form is upsert-able; staff can re-submit corrections. Schema applied to prod (prod-migration-14.sql); 20 models in sync
Changed
- Email lib sendEmail() takes optional attachments via SendEmailOptions β Postmark and Resend providers both wired to forward them as base64-encoded payload entries
- Visit page pre-visit checklist (/visit/[token]) gains a "Quick pre-visit check-in" item that activates after intake is done β keeps both forms organized in one prep flow
v2.15.02026-05-01ProductionAdded
- Inbound email attachments β Postmark webhook now decodes each attachment from base64, validates against an allowlist (PDF, common image formats, DOC/DOCX/RTF, TXT/CSV), enforces a 10 MB per-file and 25 MB per-message cap, sanitizes the filename, and uploads to private Vercel Blob at email-attachments/{messageId}/. Rejected files are logged in the message body annotation with the reason (bad MIME, too large, decode failed, etc.)
- New MessageAttachment model linking attachments to PatientMessage rows. Schema applied to prod (prod-migration-13.sql); audit:schema confirms 19 models in sync. Cascade delete on PatientMessage drops attachments too
- Admin attachment download proxy at /api/admin/messages/[messageId]/attachments/[attId] β fetches the private Blob, audit-logs the access (PHI), streams bytes back with Content-Disposition. Supports ?inline=1 for opening images/PDFs in-browser instead of forcing download
- Communication panel renders attachment chips inline below each message body β paperclip icon + filename + size pill. Click opens the proxied download. Outbound attachments not yet supported (admin sends body-only emails for now)
- Inbox row snippet shows πN when the latest message has attachments, so staff can scan a list and see which threads have files at a glance
v2.14.72026-05-01ProductionAdded
- Mailing nav badge β AdminNav "Mailing" item now shows an emerald count badge of unmailed approved authorizations (matches the messages-unread + waitlist-pending pattern). New /api/admin/mailing/count endpoint feeds it. Staff sees "Mailing 3" at a glance and knows there's a stack waiting without opening the page
v2.14.62026-05-01ProductionAdded
- Audit log labels for the missing actions: PATIENT_SELF_UPDATE (cyan), the 6 productivity events (PF_CONSENT_UPLOADED, PF_RECORDS_UPLOADED, RECORDS_REVIEWED, RECORDS_REQUESTED, ENCRYPTED_EMAIL_SENT, TELEMED_OFFERED) and the 4 integration-failure types. /admin/audit-log now renders friendly names + colored chips for every action type
Fixed
- Mobile padding finished β every remaining admin page now uses p-4 sm:p-8 pt-16 lg:pt-8 with text-xl sm:text-2xl headings and flex-wrap gap-2 header rows: /audit-log, /schedules, /promo-codes, /slots, /slots/manage, /import, /roadmap, /settings, /appointments/new, /appointments/[id], /patients/[id], /reports, /reports/health, /reports/funnel, /loading. Closes the v2.13.5 sweep β every admin route is mobile-clean
v2.14.52026-05-01ProductionChanged
- Patient portal β "Your information" card now shows email reminders + text reminders explicitly (on/off pills) instead of only flagging the off state. Patients see exactly what's configured at a glance
- Patient detail Communication panel β added an Email button next to SMS/Call buttons (gated on patient.email + !emailUnsubscribed). The Draft AI button now activates if either SMS or email is open as a channel, not SMS-only. Compose subject persists into the optimistic insert so the new email row threads correctly before the next poll
- Communication panel β "Patient hasn't consented to SMS" warning now adapts: tells staff to use email if available, or to fall back to a phone call if neither channel is open
Fixed
- Global inbox empty states now have channel-specific copy β "No email yet" / "No SMS yet" / "No call history yet" with helpful next-step text per filter, instead of one generic "No messages yet" everywhere
- Unmatched emails in /admin/messages now render as "Unknown Β· address@example.com" matching the existing "Unknown Β· β¦1234" phone style β visual consistency for unclaimed inbound from senders we don't have on file
- Late-N pill on /admin/today gains a tooltip that tells staff exactly when the no-show cron will auto-flip the appointment ("Auto-marks no-show in 12m" or "on next hourly cron run"), so staff knows whether to act now or let the cron take it
v2.14.42026-05-01ProductionAdded
- Daily briefing email expanded with three operational signals: "Awaiting provider sign-off" (PENDING_APPROVAL count, red >5, amber >0), "Unread inbox" (SMS/email/calls direction=IN status=RECEIVED, amber when nonzero), "Authorizations to mail" (approved certs without mailedAt, red >10, amber >0). Now you see all three at-a-glance every morning instead of having to log in and check each queue separately
v2.14.32026-05-01ProductionAdded
- Patient self-service profile editing β new "Your information" card on /patient/portal lets patients update their phone, address, preferred contact method, and email/SMS subscription preferences themselves. Auto-formats phone to (555) 555-5555, validates address length, audit-logs every change as PATIENT_SELF_UPDATE so staff can review unexpected updates
- GET/PATCH /api/patient/profile β patient-session-authenticated, rate-limited (10 updates/hour), returns the updated patient object with the same shape on read and write so the UI can hydrate from one source. Address changes feed through to mailing labels (mailingAddress override on appointment still wins if set by admin)
v2.14.22026-05-01ProductionAdded
- Running-late indicator on /admin/today β any SCHEDULED or CONFIRMED visit whose startsAt is 5+ minutes past now gets a red "Late {N}m" pill in the patient row plus a red row tint. Staff sees this before the hourly no-show cron fires (cron triggers at 30+ min). Tap-to-call to the patient is right there in the row to nudge them
- Global inbox header microcopy β "All inbound and outbound SMS, email & calls" replaces the SMS-only blurb now that email is wired into the inbox
v2.14.12026-05-01ProductionAdded
- Mailing queue bulk action β "Mark N as mailed" button next to Print labels in /admin/mailing. Select a batch (or use "Select all"), click once, optionally enter a shared USPS tracking number + notes, and every selected envelope gets stamped with mailedAt + tracking + notes in a single round-trip. Solves the daily 20-clicks problem when a stack of envelopes goes out together
- POST /api/admin/mailing β bulk_mark_mailed action takes ids[] + optional shared mailingTracking + mailingNotes; only updates rows where mailedAt was still null (idempotent on retry). Capped at 200 per batch
Fixed
- Mobile padding on /admin/mailing β outer p-8 β p-4 sm:p-8 pt-16 lg:pt-8, h1 text-2xl β text-xl sm:text-2xl. Was the last admin page still missing the v2.13.5 mobile pattern
v2.14.02026-05-01ProductionAdded
- Inbound email infrastructure β full pipeline so patient email replies land in /admin/messages instead of bouncing off no-reply@. New /api/webhooks/postmark/inbound-email handler with Basic-Auth verification (POSTMARK_INBOUND_AUTH=user:pass), MessageID dedupe, From-address patient matching, autoresponder/bounce drop, and PatientMessage row creation (channel=EMAIL direction=IN). Unmatched emails persist with patientId=null so staff can still see and claim them
- Outbound email Reply-To support β sendEmail() now reads EMAIL_REPLY_TO env and applies a ReplyTo header to every send. Set this to your Postmark inbound address (e.g. support@inbound.greenwellness.org) and patient replies route automatically into the new webhook. Postmark + Resend providers both wired
- Email channel in admin compose β per-patient CommunicationPanel adds an SMS/Email toggle (only shown when patient has email + emailUnsubscribed=false). Email composer adds a Subject field, expands the body to 6 rows with a 20k char cap (vs 1500 for SMS), and threads with inbound replies via the existing inbox UI
- AI draft auto-adapts to channel β /api/admin/messages/ai-draft now picks the SMS prompt or the email prompt based on the most recent inbound message's channel. Email drafts open with "Hi {firstName}," and close with "Best, Green Wellness Patient Care"; 2-5 short paragraphs, 600-token cap. The compose UI auto-flips to the matching channel and pre-fills "Re: {subject}" when the draft is for an email reply
- Global inbox /admin/messages β new Email filter tab (between SMS and Calls), Mail icon for email rows, subject prefix in the row snippet so staff can scan email threads at a glance
- Setup checklist to go live: (1) sign Postmark BAA, (2) set POSTMARK_API_KEY, POSTMARK_INBOUND_AUTH=user:pass, EMAIL_REPLY_TO=support@inbound.greenwellness.org in Vercel, (3) in Postmark Inbound Stream settings point Webhook URL to https://flow.greenwellness.org/api/webhooks/postmark/inbound-email with matching Basic Auth, (4) optional: configure DNS MX for inbound.greenwellness.org to use a custom inbound domain (else use Postmark's hash address). The webhook fail-closes when POSTMARK_INBOUND_AUTH is unset in production, so it's safe to ship before BAA is signed
v2.13.62026-05-01ProductionFixed
- Critical patient-flow bug β intake-form success and /confirm[token] success both linked "View my appointment" to /my-appointments/${cancelToken}, but that route requires a signed portal token, not a cancelToken. Patients tapping those links got a 404. Both links now point to /visit/${cancelToken}, which already accepts cancelToken and shows the visit summary, intake CTA, and join/check-in buttons
- Add-to-Calendar links in booking confirmation and reminder emails β same 401 issue: emails embedded /api/my-appointments/${cancelToken}/ics but the API only accepted portal tokens. Updated the ICS route to accept either a portal token + apptId (existing in-portal usage) OR a cancelToken alone (the email link path). The .ics download now works from any email's Add to Calendar button
v2.13.52026-05-01ProductionChanged
- Patient login page β "First time? Get a one-time login link by email β" promoted from a small footer hint to a clear secondary CTA, plus a "Accounts are created automatically when you book" microcopy line. Removes the dead-end vibe for patients who don't yet have a password
Fixed
- Mobile padding consistency on remaining admin pages (/appointments, /patients, /waitlist, /providers, /locations, /setup-2fa, /outreach): outer p-8 β p-4 sm:p-8 pt-16 lg:pt-8 so the page heading isn't covered by the hamburger menu on phones, h1 text-2xl β text-xl sm:text-2xl, header rows wrap with gap-2/3 + flex-wrap. Closes the v2.12.2 mobile polish to the rest of admin
v2.13.42026-05-01ProductionAdded
- /visit/[token] β in-person visits now show a real "Open check-in" button right in the pre-visit checklist instead of telling the patient to dig in their email. Tap-to-check-in from the parking lot
- Reschedule success state β added "We'll send a reminder 24 hours before your visit" so patients don't worry about forgetting their new time
v2.13.32026-05-01ProductionChanged
- Patient portal β first-time empty state replaced with a welcoming "Welcome, {firstName}" + 3 quick-bullet primer (15-min visit, same-day auth, tax exemption) and a "Book my appointment" primary CTA. Same polish ported to /my-appointments/[token]. Stops the cold dead-end of "You haven't booked an appointment yet" with no context
- Booking confirmation β added "Don't see it? Check your spam or promotions folder" hint right under the email confirmation line. Resend.com initial sends sometimes land in promotions; this prevents the patient from concluding nothing happened
Fixed
- DOH URL inconsistency β booking confirmation step linked to https://app.wacomm.doh.wa.gov but every other patient surface uses https://mmjr.doh.wa.gov (the actual MMJ recommendations portal). Fixed
v2.13.22026-05-01ProductionAdded
- Patient flow β /my-appointments/[token] (the email-link portal) now matches /patient/portal: Authorizations section with paperwork status (Awaiting / In review / Mailed / Ready at dispensary) and the richer "You're all set" confirmation per upcoming visit. Most patients arrive here from their confirmation/reminder emails β they now get the same clarity as logged-in patients
- Admin appointment detail β Authorization panel expanded with full mailing status. Shows mailed date, tracking number, mailing address, mailing notes when set. "Awaiting authorization" badge appears for PENDING_APPROVAL. When a cert is approved but not yet mailed, the panel surfaces a "Not yet mailed β go to Mailing queue" link so staff can fix it without digging
Changed
- Refactor: extracted authStatus() helper to src/lib/auth-status.ts so the four-state paperwork status is rendered identically across both patient portals (logged-in and email-link)
v2.13.12026-05-01ProductionFixed
- Patient flow β intake form success state now ends with a clear next-step CTA ("View my appointment details β") instead of a dead-end "See you soon!". Prior dead-end left patients unsure whether anything else was needed before their visit
- Patient flow β /confirm/[token] success state now surfaces a prominent "Complete intake form" CTA when the appointment hasn't received an intake form yet, instead of just a small "View my appointment" link. Same logic applies to the "Already confirmed" branch β patients returning to the page see the intake CTA if it's still outstanding
- Error recovery β booking wizard generic errors, /confirm errors, and Step 4 waitlist errors all now include a clickable "Call {PHONE}" tel: link. Previously the wizard said "please try again" with no escalation path; the confirm button said "or call us" without a number. Patients hitting any error can now reach a human in one tap
- Patient flow β expired intake link state now offers a "Need help? Call {PHONE}" link. Previously dead-ended at "This intake link is no longer active" with no recovery path; patient had to find the contact info on their own
v2.13.02026-05-01ProductionAdded
- Patient portal β new "Your authorizations" section that surfaces paperwork status per recent visit. Four states with distinct icons: Awaiting authorization (provider hasn't approved yet), Visit complete β in review (after visit, awaiting approval), Authorization mailed (with tracking number when present), Ready at your dispensary (when dispensary consent is on file). Download PDF link moves here from Recent history
- Patient portal β upcoming appointment cards show a richer "You're all set" banner once intake is submitted: telehealth says "Join button activates 30 min before your visit", in-person reminds patients to bring photo ID. Removes the cramped green pill in favor of a calm, instructive confirmation
- Admin dashboard β "Coming up" card now flags new patients who haven't completed intake. Header summary shows the count, each row gets an "Intake missing" pill + one-tap Call shortcut to the patient's phone, row tinted amber. Solves the recurring "call to confirm intake" workflow at the dashboard level instead of digging into individual appointments
v2.12.32026-05-01ProductionFixed
- Audit-driven mobile fixes β Directory stats grid: 4-up collapses to 2-up on phones (grid-cols-2 sm:grid-cols-4) so the four stats stop cramming into tiny columns on iPhone SE
- WhyUs comparison table: side padding px-5 β px-3 sm:px-5 so the 3-column comparison fits without label truncation on phones
- TaxSavings table: Weekly + Monthly columns hidden on phones (hidden sm:table-cell, hidden md:table-cell). Only Weekly Spend + Yearly Savings show on phones β which is what patients actually care about. Side padding px-6 β px-3 sm:px-6 to use mobile width
- Closes the 6-round mobile polish program. Final mobile readiness: public pages A-, booking wizard A, portals A, admin C (out of polish scope), nav/layout A
v2.12.22026-05-01ProductionFixed
- Mobile polish round 6 (final) β provider portal (/provider/login, /provider/[token]): login card padding p-8 β p-6 sm:p-8, dashboard padding py-8 β py-6 sm:py-8 with px-3 sm:px-4 to reach the edge on phones, appointment cards px-5 py-4 β px-4 py-3 sm:px-5 sm:py-4. Providers using iPads at the clinic now see more appointments per scroll
- Dispensary portal (/dispensary/login, /dispensary/dashboard): login card padding sm:p-8 β p-6 sm:p-8, dashboard outer py-10 β py-6 sm:py-10 with px-4 sm:px-6, h1 text-2xl β xl sm:2xl, BAA-required + error cards p-8 β p-6 sm:p-8. Active count badge wraps cleanly with whitespace-nowrap
- Admin pages (/admin, /admin/today, /admin/messages): outer padding p-8 β p-4 sm:p-8 with pt-16 lg:pt-8 reservation for the mobile hamburger menu so the heading isn't covered. Headings text-2xl β xl sm:2xl. Messages top bar wraps to two rows on phones (header above action buttons) instead of pushing buttons off-screen
v2.12.12026-05-01ProductionFixed
- Mobile polish round 5 β telehealth matrix pages (/telehealth, /telehealth/[city], /telehealth/[city]/[condition], 180+ SEO pages): hero heading sizes scale text-2xl/3xl on phones (was forcing 2-line wraps on iPhone), hero padding py-14/16 β py-10/12 sm:py-14/16, body padding py-12 β py-8 sm:py-12, section headings text-2xl β text-xl sm:text-2xl. Card padding p-6/8 β p-5/6 sm:p-6/8 throughout. Side padding px-6 β px-4 sm:px-6 so content reaches the edge on phones
- Learn / patient resources (/learn, /learn/[slug]) β same pattern: hero text-4xl/5xl β text-3xl sm:text-4xl md:text-5xl, body padding py-12 β py-8 sm:py-12. Article body card p-8 β p-6 sm:p-8, CTA card p-8 β p-6 sm:p-8 with heading scale 2xl β xl sm:2xl. Resource cards gain active:shadow tap-state
- Referral landing (/refer/[code]) β hero heading scales 4xl β 3xl on phones, page padding py-16 β py-8 sm:py-16, "How it works" heading 2xl β xl sm:2xl
- Intake form (/intake/[token]) β outer padding py-10 β py-6 sm:py-10, header text-2xl β xl sm:2xl, every section card p-6 β p-5 sm:p-6, success card p-8 β p-6 sm:p-8. Patients filling out intake on a phone now see ~30% more content per scroll
v2.12.02026-04-30ProductionAdded
- RingCentral webhook auto-renewal cron at /api/cron/rc-webhook-renew (daily 12:00 UTC) β RC subscriptions auto-expire after 7 days; this lists every Green Wellness-pointing subscription and either renews it or, if renewal fails (status=Suspended, etc.), deletes and recreates it with the same eventFilters. Idempotent. No-ops if RC env vars aren't set
- Email vendor abstraction in src/lib/email.ts β provider-agnostic sendEmail() that picks Postmark > AWS SES > Resend based on which env vars are set. workflow.ts now delegates to it. Swapping Resend β Postmark (once HIPAA BAA is signed) is a one-env-var change instead of a code change. EMAIL_FROM env overrides the From address
- Outreach campaigns gain SMS + Email-only + Both channel options on /admin/outreach β new channel selector with three options. SMS body has separate {firstName} placeholder support and 320-char cap. The send route auto-skips: SMS to patients without smsConsent, email to patients with emailUnsubscribed=true. Per-channel send/failed/skipped counts in the result. Outbound SMS rows are persisted to PatientMessage so they thread in the patient inbox and the global /admin/messages view
- AI draft edit-distance tracking on PatientMessage β outbound rows that originated as a Claude draft now carry aiDrafted=true and editDistance (Levenshtein from draft β sent body). CommunicationPanel renders a small "AI" badge inline on those messages with a tooltip showing whether it was sent verbatim or how many chars staff edited. Schema: prod-migration-12.sql, applied
- Per-patient Analytics tab on /admin/patients/[id] β new tab with three widgets: appointment status funnel (per-status bars sized by count, color-coded: Completed=emerald, Scheduled=blue, No-show=amber, Cancelled=red), visit timeline (horizontal axis from first visit β today, dots per completed visit colored amber=new vs emerald=returning, hover for date), and 12-week conversation volume sparkline (per-week bars stacking inbound vs outbound SMS/calls, hover for exact counts). Pure server-rendered, no extra round-trips
v2.11.02026-04-30ProductionAdded
- AI-suggested SMS reply drafts in the patient CommunicationPanel β when a patient has sent an inbound SMS, staff get a violet β¨ Draft button that calls Claude (via Vercel AI Gateway) with the patient's name, cert-expiry status, last appointment, and the recent thread, and pre-fills the compose textarea with a friendly, sub-320-character reply staff can edit before sending. Regenerate inline if the first take isn't right. Gated behind AI_DRAFTS_ENABLED=true env var (off by default until Anthropic BAA is in place β see TODO)
- Auto-EOD email at 5pm PT (cron at 0 1 * * * UTC) β compiles every staff member's productivity for the day, sends a branded HTML summary to ADMIN_NOTIFY_EMAIL (or all active ADMIN-role users if that env is unset). Same hero/metric-tile layout as the in-app /admin/reports/eod report, sorted by volume with top performer highlighted. Skips silently on days with no logged activity. "Open full report β" link drops you on the dashboard for that exact date
- Patient lifetime-value card on /admin/patients/[id] β compact 4-tile widget rendered once a patient has at least one completed visit. Tiles: revenue (computed from PRICING.NEW_IN_PERSON / RETURNING_TELEHEALTH minus promo discount), visits count, last visit (with days-ago badge), retention status (Active / Due soon / At risk based on the patient's own visit cadence). Headline tier badge: Pre-visit / First visit / Returning / Loyal / VIP scaling with revenue. Lets staff prioritize VIP patients at a glance
- scripts/setup-session-secrets.sh β one-shot helper to generate fresh 32-byte secrets for ADMIN_SESSION_SECRET, PROVIDER_SESSION_SECRET, DISPENSARY_SESSION_SECRET, PATIENT_SESSION_SECRET, PORTAL_TOKEN_SECRET and push them to Vercel production. Required before the next deploy now that 2.10's session signers refuse to fall back to ADMIN_PASSWORD / "dev-secret"
Fixed
- Performance: intake-reminder cron (/api/cron/intake-reminder) replaces a per-appointment hasWorkflowEventForAppointment() call (N+1) with a workflowEvents.none filter in the WHERE β same fix pattern as 2.10's reminders cron
- Performance: renewals cron 7-day escalation block replaces a per-patient db.workflowEvent.count() (N+1) with a single batched groupBy before the loop. Counts the just-logged events in-memory to preserve the β₯4-events hard-to-reach threshold semantics
- Performance: waitlist cron (/api/cron/waitlist) replaces a per-entry db.availabilitySlot.count() (N+1) with a single bounded query for all open slots in the 14-day window, then tests entries against in-memory sets. Fewer round-trips as the waitlist grows
- Performance: review-request cron (/api/cron/review-request) dedupes via workflowEvents.none in the WHERE instead of fetching workflowEvents per appointment and filtering in JS
- Performance: new-patient-drip cron β Day 3 and Day 14 queries dedupe via patient.workflowEvents.none in the WHERE; both queries also use select: only the patient fields actually rendered (firstName/email/certExpiryDate) instead of the whole record
- Performance: admin dashboard (/admin) appointment widgets β recentBookings, todaySchedule, missingVideoLink, upcomingAppts switched from include: { patient: true, location: true, provider: true } to narrow select: clauses fetching only firstName/lastName/city/name. Cuts row payload by ~60-70% on the homepage which loads on every nav
- Reliability: review-request cron now logs SMS failures (was .catch(() => {}) β fully silent) and DB siteSettings lookup failures (was .catch(() => null) without surfacing the error). Failures still don't block the cron, but they're now observable in logs
- Mobile polish round 4 β booking wizard Step 1 (Qualify) condition chips now stack 1-up on phones (were 2-up cramming long labels like "Multiple sclerosis / muscle spasms" onto two cramped lines), each chip min-h-[48px] for WCAG touch target compliance with active: tap state. Yes/No toggles also gain min-h-[48px]
- Footer compressed on phones β outer padding py-14 β py-10 sm:py-14, column gap 8 β 6 sm:8, link spacing slightly larger (gap-2.5 sm:gap-2) so adjacent links are easier to tap accurately. Bottom-bar tagline hidden on phones to keep the row from wrapping awkwardly
- About / Terms / HIPAA Notice / Privacy / Leave a Review pages all get a consistent mobile pass β heading sizes scale 3xl β 2xl on phones, hero/body padding cut roughly in half (py-14/16 β py-8/12), card padding 8 β 6 sm:8, side padding 6 β 4 sm:6. Long-form pages now read like content instead of cramped desktop layouts shoved onto a phone
v2.10.02026-04-30ProductionFixed
- Security: proxy.ts now strips client-supplied identity headers (x-admin-id, x-admin-role, x-admin-name, x-provider-*, x-dispensary-*, x-patient-id) from every incoming request before re-attaching them from a verified session. Previously a forged header could in theory pass through; this closes the spoofing surface across all four portals
- Security: admin / provider / dispensary / patient session secrets, the portal-token signer, and the unsubscribe-token signer now throw on missing env var in production instead of falling back to a hard-coded "dev-secret". Forces deploys to fail loudly if a secret was forgotten rather than silently signing with a guessable value
- Security: rate limiter gains a fail-closed mode used by login, password reset, and other PHI-adjacent endpoints. If the Upstash backend errors during a check, sensitive endpoints block the request rather than allowing it through. Non-sensitive endpoints still fail open to preserve availability
- Security: magic-link TTL reduced from 1 hour to 15 minutes (src/lib/portal-token.ts) β tightens the HIPAA exposure window if a magic-link email is forwarded or intercepted
- Reliability: promo-code usage increment moved inside the booking transaction β eliminates the race where two concurrent bookings of a single-use promo could both succeed because the increment was a fire-and-forget call after commit
- Performance: reminders cron (/api/cron/reminders) now dedupes at the DB level via workflowEvents.none β previously fetched every appointment in each window with full patient/provider/location relations and filtered in memory. Saves a growing amount of bandwidth as the appointment table fills
- Intake paperwork no longer asks patients for qualifying conditions twice β the intake form now pre-populates from the booking-time selection with a "Pre-filled from your booking β add or remove as needed" hint. Conditions list unified across the booking wizard and intake form via a single CONDITIONS export in src/lib/constants.ts (now 14 conditions; sleep disorders, nausea/appetite loss, and TBI which were intake-only are now in both)
- Admin pages no longer show raw condition IDs (e.g. "chronic_pain, ptsd") β appointment detail, waitlist table, and the Top Conditions funnel report all display human labels via a new conditionLabel() helper
- Communication panel anchor β admin patient page gains id="communication" with scroll-mt offset so deep-links from notifications land on the SMS/call thread instead of the page header
- Mobile polish round 3 β patient portal (/patient/portal) and magic-link page (/my-appointments/[token]): page padding compressed py-12 β py-6 sm:py-12, header text scales 2xl β xl sm:2xl, appointment cards p-5 β p-4 sm:p-5 so more fits without scrolling
- Token pages (/confirm, /cancel, /reschedule, /checkin) β outer padding tightened on phones; cancel/confirm card padding p-8 β p-6 sm:p-8, reschedule wrapper py-12 β py-6 sm:py-12. Heavy py-16 padding on cancel page now py-8 sm:py-16
- Locations city pages (/locations/[city]) β hero heading scales text-3xl on phones (was text-4xl forcing two-line wrap), hero padding py-16 β py-12 sm:py-16, conditions checklist grid collapses to single column on phones (was 2-up making each item ~150px wide and unreadable)
- Provider detail page (/providers/[slug]) β name heading scales text-2xl on phones (was 3xl pushing CTA below fold), main grid padding py-10 β py-6 sm:py-10
Added
- Integration-failure audit actions (SF_INTEGRATION_FAILURE, PF_INTEGRATION_FAILURE, EMAIL_INTEGRATION_FAILURE, SMS_INTEGRATION_FAILURE) β booking flow now logs an audit row whenever an outbound integration call returns a non-2xx or throws, so staff can manually reconcile from the appointment detail view instead of silently dropping the failure
- Integration-failure banner on /admin/appointments/[id] β amber banner surfaces any logged failures for that appointment (channel, timestamp, error detail) with a prompt to reconcile in the upstream system. Closes the gap where Salesforce or Practice Fusion would silently fail and staff would never know
- Rate limiting on /api/intake (per-IP 30/hr + per-token 10/hr, fail-closed) β closes a gap where UUID intake-form tokens could be enumerated to exfiltrate PHI (conditions, medications, allergies)
- Rate limiting on /api/admin/forgot-password and /api/admin/reset-password (both were unprotected) β fail-closed to defeat token brute-forcing and email-bombing of admin reset flow
v2.9.12026-04-30ProductionFixed
- Telehealth visit page (/visit/[token]) on mobile β Join button is now full-width with a 52px min-height and an active: tap state, button text truncates instead of wrapping awkwardly. Camera+mic preview video is now responsive (full-width on phones, fixed 192px sidebar size on tablets+) so patients can actually see themselves before joining. Page padding tightened from py-10 to py-6 sm:py-10
- Booking wizard Step 3 (Appointment type): the Telehealth/In-Person toggle stacks single-column on mobile instead of cramping side-by-side at <640px
- Booking wizard Step 4 (Time picker): month-nav arrows expanded from p-1 (~26px) to p-2 (~36px) for easier tapping; time-slot grid stays 3-up on phones but goes 4-up at sm:+ to use wide screens better; each time slot is now min-h-[44px] (WCAG touch target compliant) with a tap-state border
- Hero section heading scales down on mobile (text-4xl on small phones, text-5xl at sm:+, text-6xl/7xl on tablet+) so the headline doesn't push the CTA below the fold on iPhone SE. Hero vertical padding compressed from py-28 to py-16 sm:py-24 on phones
- Locations + Physicians + Services + Hero section padding tightened on mobile (py-16 vs py-20/24), card grid gaps reduced from gap-6 to gap-4 sm:gap-6 β more content visible without scrolling
- Locations grid breakpoint: now sm:grid-cols-2 (was md:grid-cols-2) so phones get 2-up at 640px instead of 768px, matching natural breakpoint
- Physicians cards added active:shadow tap-state for mobile (was hover-only)
v2.9.02026-04-30ProductionAdded
- End-of-Day report at /admin/reports/eod β auto-generated per-agent productivity rollup that mirrors the format Doug's staff have been writing by hand. Sections (aβe) for Practice Fusion uploads / patient communication / Salesforce / missing-records follow-ups / medical records reviewed. Hero card with animated total-actions count-up, gradient + grid pattern, live indicator when viewing today. Metric tiles: total actions, active staff, voicemails, top-performer with trophy. Activity heatmap by hour with peak-hour callout. Per-staff cards sorted by volume; top performer gets amber border + trophy badge; first β last action time range shown per agent. Date picker with prev/next/today buttons + staff filter dropdown. CSV export at /api/admin/reports/eod/export. Linked from the Marketing nav as "EOD Report"
- Productivity Quick-Log actions extended β six purpose-built buttons on the patient detail Quick Log panel (PF consent uploaded, records uploaded, records reviewed, missing-records follow-up, encrypted email sent, telemed offered). "Reviewed records" opens a modal capturing qualified/not-qualified + condition tags + availability provided so the EOD shows the same level of detail as the manual report
Fixed
- iOS Safari zoom-on-focus across every patient-facing form β every input that was 14px (text-sm) now uses text-base (16px) on mobile and text-sm at sm: breakpoint. Affects: patient login + forgot-password, patient portal change-password, password reset, magic-link request on /my-appointments, set-password on /my-appointments/[token], intake form (textarea + free-text inputs), promo code field in the booking wizard. Patients no longer get the zoomed keyboard that breaks layout when tapping a field
- Booking wizard modal on iPhone β max-h now uses max-h-[90dvh] (with 90vh fallback) so the iOS Safari address bar stops overlapping the bottom of the wizard. Mobile margin tightened from mx-4 β mx-2 and inner padding from p-7 β p-4 sm:p-7. Wizard now fits cleanly on iPhone SE (375px) without horizontal scroll
- Step 2 (About You) booking form fields collapse to a single column on mobile β first/last name, DOB/phone, email, contact-method buttons, and the new-vs-returning toggle. The 2-column grid that was making labels unreadable below 640px is now grid-cols-1 sm:grid-cols-2
- Tax-savings 3-column grid on /conditions/[slug] no longer overflows on phones β collapses to single column, 3-up on tablets+
Changed
- Site nav mobile menu button bumped from p-1 (~22px) to p-2.5 (~40px) for a proper touch target, with a subtle active:bg-white/10 tap state
- Conditions list cards (/conditions) now show their hover treatment on mobile tap (active:shadow-md + active:border) β previously the styling was hover-only so taps had no visual feedback
v2.8.02026-04-30ProductionAdded
- Patient profile reorganized into tabs β Appointments / Messages / Documents / System log. The header card (name, contact info, cert expiry, edit, send portal link, etc.) stays pinned above. Hash routing preserved: /admin/patients/[id]#communication still lands on Messages, with /admin/messages deep-linking to the right tab
- New Documents tab β surfaces patient-uploaded medical records (which were already being captured but never visible in admin). Per-row: filename, size, upload date, source badge (Patient / Admin), Open and Delete buttons. Wires into the existing /api/admin/documents/[id] proxy + DELETE endpoint
- Tab badges β Messages tab shows a red unread count when there are RECEIVED inbound messages for this patient; other tabs show neutral counts so admins can see at a glance how much history exists
- Quick log panel + per-agent audit attribution β admins can now log a manual action (call attempt, voicemail, in-person note) directly from the patient profile, and every audit row gets stamped with the staff user who took the action. Sets up the next layer of EOD productivity reports. Schema: AuditLog.staffUserId + staffUserName (prod-migration-11.sql, applied)
v2.7.02026-04-30ProductionAdded
- Auto-poll on /admin/messages and CommunicationPanel β every 30s while the tab is visible (pauses when hidden, resumes on focus). Inbound SMS and call rows appear without a page refresh
- Compose to non-patient on /admin/messages β "New SMS" button opens a modal with patient search OR raw phone entry. Sending to a phone that later matches a patient threads automatically; unmatched messages stay in the inbox under "(unknown Β· β¦)"
- Call recording capture β RC call webhook persists recordingUrl when present; CommunicationPanel shows a "Listen" link inline on call rows, served via signed proxy at /api/admin/messages/[id]/recording (RC bearer auth happens server-side)
- Webhook signature verification β verifyToken() in src/lib/rc-webhook.ts uses crypto.timingSafeEqual against RC_WEBHOOK_VERIFICATION_TOKEN, eliminating the timing-attack surface on per-delivery auth. Both RC webhooks (sms + calls) routed through shared helpers
- scripts/rc-register-webhooks.mjs β one-shot CLI that authenticates with RC, deletes stale Green Wellness subscriptions, and creates fresh ones for inbound SMS + telephony sessions. Re-run weekly to refresh the 7-day expiry, or wire as a cron
Changed
- POST /api/admin/messages/send now accepts either { patientId, body } or { phone, body } β feeds the new compose-to-non-patient flow on the global inbox
- PatientMessage schema gains recordingUrl column (prod-migration-10.sql) β null for SMS rows and for calls without recording enabled
v2.7.12026-04-30ProductionChanged
- Condition list expanded (sleep disorders, nausea/appetite loss, traumatic brain injury) and renamed for consistency
- New conditionLabel() helper in src/lib/constants.ts renders human-readable labels in the admin appointment detail page, waitlist, funnel report, and intake form, instead of raw IDs like "chronic_pain"
v2.6.02026-04-30ProductionAdded
- Global messages inbox at /admin/messages β single triage view of every inbound + outbound SMS and call across all patients, grouped by patient, with filter pills for All / Unread / SMS / Calls. Click a row to jump straight to that patient's CommunicationPanel anchor
- AdminNav: new "Messages" entry under Patients with a red unread-count badge polled from /api/admin/messages/unread-count β mirrors the amber waitlist badge pattern
- Read tracking: opening a patient's CommunicationPanel automatically POSTs to /api/admin/messages/mark-read so all that patient's RECEIVED messages flip to READ β clears them from the global inbox and the nav badge
- API: GET /api/admin/messages (with unread / channel / direction filters), GET /api/admin/messages/unread-count, POST /api/admin/messages/mark-read
v2.5.02026-04-30ProductionAdded
- RingCentral / AT&T Office@Hand integration β new src/lib/ringcentral.ts adapter (JWT auth, token cache, sendSms + ringOut). workflow.ts auto-prefers RingCentral when RC_* env vars are set, falls back to Twilio otherwise. All 6 existing SMS code paths (booking confirmation, reschedule, no-show, single + bulk reminders, appointment reminder) flip over with no caller changes
- Inbound SMS webhook at /api/webhooks/ringcentral/sms β handles RC subscription handshake, persists messages to PatientMessage table, auto-links to patient by phone match, re-implements Twilio-era STOP/START opt-out semantics
- Call-log webhook at /api/webhooks/ringcentral/calls β records inbound + outbound calls (direction, duration, both party numbers), auto-links to patient by phone match
- PatientMessage model β unified per-patient inbox for SMS, calls, and (future) email threads. Indexed for fast per-patient lookup
- Click-to-text and click-to-call on /admin/patients/[id] β new CommunicationPanel component with chronological SMS/call thread, in-place compose, tel: deeplink for outbound calls, smsConsent gating, length counter, per-message status (SENT/FAILED/duration)
- POST /api/admin/messages/send β internal click-to-text endpoint (gated on smsConsent, logs outbound row, audited)
- POST /api/admin/messages/call β RingOut click-to-call endpoint (rings staff first, then dials patient β keeps staff numbers private)
Changed
- Patient detail page splits 'Communication Log' into two: the new direct SMS/call thread (staff β patient) sits above, and the existing system log (auto-reminders, drips) is renamed 'System log'
v2.4.02026-04-30ProductionAdded
- Branded telehealth visit page at /visit/[token] β patients land on greenwellness.org for their video visit instead of clicking straight to Doxy.me. Includes time-aware Join button (locked until 30min before start), camera + mic test (browser getUserMedia API), live preview of the video feed, and a per-visit prep checklist (intake form status, ID ready, quiet space, charged device)
- All telehealth confirmation, reminder, reschedule, and resend-confirmation emails now route patients through the visit page instead of linking to the raw Doxy.me URL β keeps the brand experience and surfaces the camera test before they enter the waiting room
- SMS reminders for telehealth visits (24h + 2h) now include the visit page link inline so patients can tap straight from the message β previously SMS had no join link at all
- ICS calendar invite (Add to Calendar) now embeds the visit page URL in both DESCRIPTION and the standard URL property, so tapping the calendar event on phone/desktop opens the launch page
v2.3.02026-04-30ProductionAdded
- HIPAA-compliant telehealth via Doxy.me β per-provider waiting room URL stored on the Provider record and used as the default videoLink for new bookings. Replaces the previous auto-generated Jitsi links which had no BAA
- Patient portal + my-appointments: prominent "Join your visit" CTA renders within 30 min of start (and through end-of-visit + 30 min) with a "no app needed" hint when the link is a Doxy.me room
- Email + SMS templates: telehealth confirmations and reminders now resolve effectiveVideoLink (appointment.videoLink ?? provider.doxyMeUrl) so legacy appointments without a per-appt link still get the provider's Doxy.me room
- Provider portal: shows "Open my Doxy.me room" when the appointment has no per-appt override but the provider has a default room set
- Admin β Providers β Edit: new Doxy.me URL field with help text and link to doxy.me signup; "Doxy.me" badge on provider list when configured
- Schema-vs-DB audit script (npm run audit:schema) + scripts/audit-schema.mjs β catches missing migrations before they reach prod and crash queries with P2022
Fixed
- Critical: "Something went wrong" error on /admin and the cron reminders job β caused by Patient.emailUnsubscribed and Appointment.hipaaConsentedAt/evalConsentedAt existing in the Prisma schema but never having been migrated to prod. Captured in prod-migration-8.sql
v2.2.02026-04-30ProductionAdded
- Mailing tracker (/admin/mailing): queue of authorizations awaiting physical mailing, with one-click Avery 5163 / 8163 label PDF generation (10 labels per US Letter sheet, 2"Γ4")
- Mark-mailed flow with optional USPS/UPS tracking number and freeform notes β plus a 'Mailed' tab showing every cert that has been sent and full edit/unmark controls
- Cert service requests: paid resends ($25) for lost certs and changes ($50) for adding a designated provider, address change, or other modifications β with status workflow (Pending β Paid β Completed) and patient search
- Schema: Appointment.mailedAt / mailingTracking / mailingNotes / mailingAddress override; Patient.designatedProviderName (for WA RCW 69.51A); new CertServiceRequest table
v2.1.12026-04-30ProductionFixed
- Provider portal: authorization/no-show/video-link actions now show a red error message if the API call fails β previously gave false 'nothing happened' impression
- Admin schedules: delete now checks API response before removing from UI β prevents ghost deletions where item disappears locally but persists on server
- Admin locations: toggleActive and save() now show error toast on failure instead of silently resetting
- Scheduling wizard Step 4: waitlist join failure now shows 'Could not join waitlist β please try again' instead of silently resetting the button
v2.1.02026-04-30ProductionAdded
- Admin patients list: 'Book β' quick-link in every row β opens /admin/appointments/new with the patient pre-filled
- Dispensary BAA toggle: success/error toast on every toggle action
- Dispensary active toggle: error toast on failure
Changed
- Patient portal and my-appointments: empty 'Upcoming' state now distinguishes three cases β never booked, has past appointments, or has completed appointments (shows 'Book renewal' CTA for returning patients)
- Admin patients list: empty state now shows context-aware message and 'Clear filters' CTA when filters are active
v2.0.92026-04-30ProductionAdded
- HIPAA consent audit trail: hipaaConsentedAt and evalConsentedAt timestamps now stored on every appointment (schema migration: prod-migration-consent.sql)
- evalConsent validation added to booking API β server now requires and records explicit evaluation consent at booking time
- Rate limiting on /api/availability (120 req/IP/min) and /api/promo/validate (10 req/IP/min) β prevents slot enumeration and promo brute-force
- Intake form token expiry: access blocked 7 days after appointment date β limits indefinite PHI exposure via cancelToken links
Changed
- Stripe payment intent metadata no longer contains PII (name, email, phone removed) β data minimization for HIPAA compliance; recovery path now alerts admin for manual follow-up
- Step 3 scheduling wizard: telehealth option hidden (not just disabled) for new patients β cleaner UX
Fixed
- Step 4 time selection: availability fetch failures now show error + retry button instead of hanging spinner
v2.0.82026-04-30ProductionFixed
- Persistent React #418 hydration error: added suppressHydrationWarning to and β prevents browser extensions (Grammarly, Google Translate, etc.) from causing hydration mismatches
- GA scripts moved from raw
- π‘οΈ NEW arc-guard