Skip to main content

Changelog

Every release, every change

Roughly weekly cadence. Significant changes get full release notes; everything else lands quietly via continuous deployment.

Upgrading? Read the migration guides.

Most releases are additive and need no upgrade work. For the rare breaking changes, /docs/migrations has per-version guides with before/after diffs, mapping tables, and SDK-specific notes.

v5.3.0-alpha.114

Revert the fail-closed default (HELP-11) — restore fail-open. The alpha.113 flip surfaced that the pervasive runWithX(() => prisma.op(...)) wrap pattern never actually propagated AsyncLocalStorage context (the PrismaPromise executes lazily outside the run() scope); those queries are scoped by their manual where: { tenantId } filters, not the wrap. Under fail-closed that pattern throws, breaking public KB/legal SSR pages and integration routes. Reverted to the fail-open default to restore a clean, env-independent state.

noteReverted

  • packages/db tenant-scope + apps/worker job-context return to fail-open default with HELPMESH_TENANT_STRICT=1 as the opt-in strict mode (pre-alpha.113 behavior).

noteRetained (all genuine hardening from alpha.108–112 stays)

  • Context wrapping at the boundaries that DO hold context: apiHandler request wrapping, worker dispatch, widget, inbound webhooks. Tenant-resolution + ingest + SSR + bypass-route wraps. Audit mode (HELPMESH_TENANT_AUDIT=1). All manual tenantId filters and app-layer guards.

noteFollow-up (true fail-closed, done right)

Convert every single-promise runWithX(() => prisma.op()) wrap to async () => prisma.op() codebase-wide (mechanical), add a route-level strict test harness / staging DB to exercise every tenant-scoped path under strict *before* prod, then re-flip. Not a passive soak.

v5.3.0-alpha.113

Tenant scoping is now fail-closed by default (HELP-11 — complete). A tenant-scoped Prisma query that runs without tenant context now throws instead of silently returning unscoped data. This is the culmination of the HELP-11 series (alpha.108–112), which wrapped every pre-context path first.

noteSecurity — changed

  • `packages/db` tenant-scope extension: a missing AsyncLocalStorage store on a tenant-scoped model throws by default. A forgotten runWithTenant()/runWithSuperAdmin() is now a loud 500, not a silent cross-tenant read. HELPMESH_TENANT_STRICT=0 is an emergency break-glass that restores the old fail-open pass-through without a code deploy; explicit-null tenantId always throws.
  • `apps/worker` dispatch: an unclassified job with no tenantId (and not in the cross-tenant allowlist) throws by default; same HELPMESH_TENANT_STRICT=0 break-glass.

noteValidation

  • Reached only after wrapping HTTP resolution, worker dispatch, widget, inbound webhooks, MFA pre-check, SSR pages, legal directories, and ~11 apiHandler-bypass routes — discovered via production audit mode + a static sweep.
  • Strict was run on live production for 30 minutes with 0 throws before flipping the default. Tests pin the new default + break-glass; db 229 / worker 224 / web 1682 green.

v5.3.0-alpha.112

Remaining pre-context paths scoped (HELP-11, step 5). A static sweep (complementing production audit mode) found the low-traffic paths organic traffic hadn't exercised. No production behaviour change — the fail-open default is unchanged.

fixFixed

  • Public legal pages /legal/dpo and /legal/ropa-summary: cross-tenant public directories (DpoContact / RopaActivity where isPublic) now read under runWithSuperAdmin.
  • API routes that bypass `apiHandler` and query tenant-scoped models pre-context, now wrapped:

- ai/sentiment, ai/search-articles, system/activity, privacy/consent|export|delete, invoices/[id]/pdf|resend-emailrunWithTenant (session/invoice tenant). - system/health (global counts), public/reports/[shareToken] (share-token lookup), and billing/webhook's tenant-resolution helpers + cross-tenant referral grant → runWithSuperAdmin; each Stripe event handler's writes → runWithTenant(tenant.id).

noteNotes

  • Full web suite green under HELPMESH_TENANT_STRICT=1 (1682 tests); billing/webhook diff reviewed line-by-line. With this, all statically- and runtime-discovered pre-context paths are wrapped — clearing the way for env-flip re-validation and the production fail-closed flip.

v5.3.0-alpha.111

SSR page tenant context (HELP-11, step 4). Wraps the public knowledge-base pages and the branding preview, whose render-time queries production audit mode flagged as running without tenant context. No production behaviour change — the fail-open default is unchanged.

fixFixed

  • Public KB pages ((kb)/[tenantSlug] layout, home, [collectionSlug], articles/[articleSlug]): tenant is now resolved by public slug under runWithSuperAdmin (cross-tenant), then its collections/articles and the powered-by-badge check load under runWithTenant(tenant.id). Previously these server components queried Tenant/Collection/Article with no context.
  • Branding preview (/preview/branding): the session-tenant lookup now runs under runWithTenant(activeTenantId).

noteNotes

  • Source-contract guard tests added; web suite green under HELPMESH_TENANT_STRICT=1.
  • Found via HELPMESH_TENANT_AUDIT=1 (alpha.110) on live traffic. Audit mode remains enabled in prod to enumerate any remaining unwrapped paths before the fail-closed flip.

v5.3.0-alpha.110

Tenant-context observability + MFA pre-check fix (HELP-11, step 3). Groundwork to safely reach a production fail-closed flip. No production behaviour change with audit mode off (the default).

featureAdded

  • Audit mode (HELPMESH_TENANT_AUDIT=1): logs one structured line per distinct (model.operation @ call-site) for every tenant-scoped Prisma query that runs without tenant context, then passes through unchanged (never throws). Lets the full set of unwrapped pre-context call sites be enumerated from real production traffic before flipping fail-closed — instead of discovering them by 500-ing paths one at a time. Logs code locations only (no query args or row data); deduped per process.

fixFixed

  • MFA enforcement pre-check (checkMfaRequired) issued tenant.findUnique before the handler's runWithTenant, so it fired context-less on every authenticated mutation — a confirmed source of strict-mode throws. Now wrapped in runWithTenant(tenantId).

noteNotes

  • Audit-mode unit tests added; db + web suites green.

v5.3.0-alpha.109

Tenant context on unauthenticated ingest paths (HELP-11, step 2). Extends the fail-closed groundwork to widget entry points and inbound-channel webhooks. No production behaviour change — the fail-open default is unchanged.

noteChanged — ingest routes

  • Twilio (SMS + voice): inbox resolution now runs under runWithSuperAdmin; all customer/thread/message writes run under runWithTenant(inbox.tenantId) (previously executed with no tenant context).
  • WhatsApp: inbox resolution wrapped in runWithSuperAdmin; each message persisted under runWithTenant(inbox.tenantId).
  • Slack, Postmark, widget sessions: the cross-tenant resolution lookup now runs under runWithSuperAdmin (their per-tenant processing was already scoped).
  • Widget KB: popular-articles query wrapped in runWithTenant; the full-text search path is raw SQL with an explicit tenantId filter (unaffected by the scope extension).

noteNotes

  • Adds a source-contract guard test for the ingest wrappers. Web suite green under HELPMESH_TENANT_STRICT=1.
  • With this, the known pre-context surfaces (worker dispatch, HTTP tenant resolution, widget + inbound webhooks) all declare intent explicitly — clearing the way for an env-flip validation ahead of the production fail-closed switch.

v5.3.0-alpha.108

Tenant-context hardening at the worker-dispatch and tenant-resolution boundaries (HELP-11, step 1). Groundwork toward running the tenant-scope extension fail-closed (HELPMESH_TENANT_STRICT=1) in production. No production behaviour change — the fail-open default is unchanged.

noteChanged — data layer / workers

  • Worker jobs establish tenant context at the dispatch boundary (HELP-11): a new runJobWithContext wraps every job once — jobs carrying tenantId run under runWithTenant, the cross-tenant maintenance/fan-out crons run under runWithSuperAdmin, and an unclassified context-less job warns (or fails under strict). Covers all 37 processors centrally instead of per-processor wrapping; fixes the sla-check sweep that threw under a strict-mode trial.
  • Tenant resolution runs under explicit super-admin context (HELP-11): resolveTenant (API handler) and the public-KB hostname resolver now run under runWithSuperAdmin, since resolution is inherently cross-tenant and precedes per-tenant context. The real per-tenant context is still established by the handler afterward.

noteNotes

  • Verified green under HELPMESH_TENANT_STRICT=1: web 1669 · worker 224 · db 222 · core 591.
  • The production fail-closed flip itself remains gated on wrapping the remaining widget / inbound-webhook pre-context paths and a clean full validation run.

v5.3.0-alpha.107

Tenant-isolation & input-trust hardening (security). Five coordinated PRs from the 2026-07-07 product audit.

noteSecurity — fixed

  • Cross-tenant access via `X-Tenant-Id` / `x-user-id` headers removed (HELP-10, HELP-17): tenant and acting-user identity now derive only from authenticated sources (API key / session / hostname). Header dropped from CORS.
  • Pusher channel authorization (HELP-13): /api/pusher/auth verifies every channel against the caller's active tenant before authorizing (tenant channels by equality; thread/presence by a tenant-scoped lookup). Parity with the customer-side widget authorizer.
  • Webhook SSRF blocked (HELP-15): new @helpmesh/core SSRF classifiers (ipaddr.js) reject targets resolving to private/loopback/link-local/metadata IPs — at registration and, authoritatively, at delivery. Stored response bodies capped at 2KB.
  • HTML sanitizer replaced with DOMPurify (HELP-14): isomorphic-dompurify (real DOM allow-list) supersedes the bypassable regex, making the long-standing "sanitised with DOMPurify" claim true. Regression tests cover the nested-script and entity-encoded javascript: bypasses.

noteChanged — data layer

  • Tenant scoping is schema-derived (HELP-12): the scoped-model set comes from Prisma.dmmf (every model with a tenantId column — 54) instead of a hand-maintained 20-model list; a new tenant-owned model can no longer be forgotten and leak.
  • Admin routes carry explicit super-admin context (HELP-94): getSuperAdmin() establishes it once, covering all 25 /api/admin routes.
  • CI runs the unit suite fail-closed (HELPMESH_TENANT_STRICT=1, HELP-11 partial): a forgotten runWithTenant() now fails the build. Production default flip deferred.
  • next.config: merged the duplicate experimental block that had been dropping serverComponentsExternalPackages (HELP-101); externalized isomorphic-dompurify.

noteTests

  • core 591 · db 222 · web 1683 · worker 217 — all green, including under fail-closed strict mode. next build clean.

v5.3.0-alpha.106

Inbox-v2 phase 2 — real per-view counts + "no top bar" shell.

noteAdded — web (inbox-v2)

  • `GET /api/v1/threads/counts`: one aggregate endpoint returning real per-view / channel / label thread counts. Tenant-scoped on every query (label counts scope through the thread relation since ThreadLabel has no tenantId); channels/labels via groupBy (no N+1). Pure, unit-tested mappers in apps/web/src/lib/thread-counts.ts.
  • Count badges on the Inbox Views column, refreshed whenever the list refreshes. A badge renders only for a positive count — an absent badge means genuinely zero, never a fabricated number.
  • Inbox list filter: a "Filter conversations…" field over the loaded list (subject / customer name / email), with a "no matches" state. ⌘K remains the global search.

noteChanged — web (shell)

  • The global top bar is hidden on `/inbox` at `xl`+ (the width where the Views column appears), reclaiming vertical space to match the inbox-v2 design. Below xl it stays.
  • New · Notifications · Account moved from the top bar into the global sidebar rail (bottom cluster), so they stay reachable app-wide — including on the inbox and in the mobile slide-in nav. Affects every agent page's chrome; the top bar is now search + mobile-nav only.

noteTests

  • +4 web (count mappers). Build · typecheck · lint clean; threads suite green.

v5.3.0-alpha.105

Inbox-v2 phase 1 + white-label badge render enforcement.

noteAdded — web (inbox-v2, surgical/additive)

  • Inbox Views column (InboxViews): net-new left navigator with Views (All open · Urgent · Pending · Assigned to me · Unassigned · Snoozed), Channels (Email · Live chat · API), and Labels (live from /api/v1/labels). Wired to the inbox's existing filter state — no data-layer rewrite. Persistent at xl (≥1280px); supersedes the TabBar at that width, which still drives tabs below it.
  • `GET /api/v1/threads`: new channel and label (labelId) filters, built through a pure, unit-tested apps/web/src/lib/thread-filters.ts (buildThreadFilterWhere).
  • View counts are intentionally not rendered yet — avoids invented data; a real aggregate endpoint is the next phase.

noteFixed — web (white-label)

  • The "Powered by HelpMesh" badge now respects showPoweredBy. It was gated on *enable* (alpha.103) but ignored at *render*, so paying white-label tenants still saw it on KB, customer portal, and widget-session surfaces. Hidden only when the tenant both opted out and currently holds the whitelabel entitlement (re-checked at render, so a downgrade restores the badge). New shouldShowPoweredByBadge() + pure poweredByBadgeVisible().

noteTests

  • +8 web (3 entitlements render-gate, 5 thread-filters). Build · typecheck · lint clean.

v5.3.0-alpha.104

Super-admin Phase 4 — Performance monitoring. The last Phase 4 surface, built entirely from real recorded data (no CloudWatch, no synthetic metrics).

noteAdded — web (super-admin)

  • Performance (/admin/performance + GET /api/admin/performance): platform performance from rows the system already records —

- Webhook delivery (7d): volume, success rate, average latency (WebhookDelivery.duration), plus a 7-day daily-volume trend. - Event processing: outbox backlog (pending), events processed in the last hour, and average processing lag (processedAt − createdAt) over recent events. - Throughput: messages + threads created in the last 24h / 7d. - DB ping latency. New Operations → Performance sidebar entry. Dark-themed. Aggregation math in a pure, unit-tested apps/web/src/lib/admin-performance.ts.

noteNote

  • This completes Phase 4 across the four surfaces with a real data source (billing, alerts, incidents, performance). Per-service infra metrics (CloudWatch/AWS) remain out of scope — they have no in-app data source and are not stubbed. This page is explicitly labeled "application-level metrics (not infra/CloudWatch)".

noteTests

  • Web suite 1657 passing (+7: 5 helper + 2 route).

v5.3.0-alpha.103

Entitlement enforcement. Activates the per-tenant entitlement system (shipped dormant in alpha.93) by wiring it into the three capability gates — non-breaking.

noteChanged — web (authorization)

  • New tenantHasCapability(tenantId, key) + pure capabilitiesRequiredForSettingsPatch(body) helpers (apps/web/src/lib/entitlements.ts), both resolving plan default ⊕ super-admin override.
  • customDomainPOST /api/v1/tenant/email-domain now returns 403 HM-PLAN-INSUFFICIENT unless the tenant holds the customDomain capability.
  • ssoPATCH /api/v1/tenant returns 403 HM-PLAN-INSUFFICIENT when setting a non-null samlConfig without the sso capability.
  • whitelabelPATCH /api/v1/tenant returns 403 when hiding the "Powered by" badge (showPoweredBy=false) without the whitelabel capability.
  • Non-breaking by design: only *enabling* a paid feature is gated. Clearing/disabling (samlConfig=null, showPoweredBy=true, BYOD DELETE/GET) is always allowed, so tenants already using a feature are never cut off mid-stream.

noteNote

  • Honoring showPoweredBy at *render* time (KB/portal layouts + the embeddable widget currently always show the badge) is a separate, pre-existing wiring gap — not addressed here. This ship governs the *setting*, not the badge render.

noteTests

  • Web suite 1650 passing (+6 entitlement helper/decision).

v5.3.0-alpha.102

Super-admin Phase 4 — Incidents subsystem. A real, admin-managed platform incident log (migration-bearing).

noteAdded — platform / web (super-admin)

  • `Incident` + `IncidentUpdate` models (migration 20260608000000_platform_incidents). Platform-level — no `tenantId`, deliberately omitted from TENANT_SCOPED_MODELS so the tenant middleware never scopes them. Purely additive (two new tables + two enums).
  • API: GET/POST /api/admin/incidents, GET/PATCH /api/admin/incidents/[id], POST /api/admin/incidents/[id]/updates (appends a timeline entry and advances incident status in one transaction; RESOLVED stamps resolvedAt). Super-admin only; Zod-validated via new @helpmesh/core incident schemas.
  • UI: /admin/incidents (list + inline open-incident form, open-count) and /admin/incidents/[id] (severity/status, impact, affected tenants, post-update form, reverse-chronological timeline). New Operations → Incidents sidebar entry. Dark-themed.
  • All data is admin-entered — nothing synthetic. (No AuditLog entry: that model requires a tenantId; incidents are platform-level and carry their own timeline + createdByUserId trail.)

noteTests

  • Core suite 582 passing (+6 incident schema/helper), web suite 1644 passing (+4 incident route).

v5.3.0-alpha.101

Super-admin Phase 4 — cross-tenant Alerts (attention feed). Second net-new admin surface, again built on real conditions only.

noteAdded — web (super-admin)

  • Alerts (/admin/alerts + GET /api/admin/alerts): a cross-tenant attention feed that aggregates real platform conditions — overdue/failed GDPR deletions (72h SLA breach → critical), auto-disabled webhooks (→ warning), and billing states (unpaid → critical, past-due → warning, trial expiring ≤3d → info). Severity-summarized tiles + a deep-linked alert list with an "All clear" empty state. New Operations → Alerts sidebar entry. Dark-themed.
  • Aggregation logic lives in a pure, React/DB-free buildAlerts/summarizeAlerts (apps/web/src/lib/admin-alerts.ts) so it is fully unit-tested. Categories with no backing data simply yield no alerts — nothing synthetic.

noteNote

  • Remaining Phase 4 surfaces incidents and performance are still not built — no real data source (no incident model; perf would need a CloudWatch/health time-series). They will not be stubbed with fabricated numbers.

noteTests

  • Web suite 1640 passing (+9: 7 aggregation + 2 route).

v5.3.0-alpha.100

Super-admin Phase 4 — cross-tenant billing overview. First of the net-new admin surfaces, built on real data only.

noteAdded — web (super-admin)

  • Billing (/admin/billing + GET /api/admin/billing): cross-tenant revenue overview — estimated MRR/ARR, active-subscription / past-due / cancelling counts, MRR-by-plan bars, a per-tenant subscription table, and the 10 most recent invoices (from the local Invoice mirror, with Stripe PDF links). New Revenue → Billing sidebar entry. Dark-themed.
  • Honest MRR basis: a new shared estimateTenantMrrCents (packages/core/src/billing/mrr.ts) computes MRR from plan list-price × paid seats, normalizing annual to monthly. It is not Stripe-invoiced revenue — coupons/credits/proration/tax are ignored and ENTERPRISE (contact-sales, list price $0) counts as $0. The UI labels this clearly. Only ACTIVE subscriptions count toward MRR.

noteNote

  • The remaining Phase 4 surfaces (incidents, performance, alerts) are still not built — they have no backend data source and will not be stubbed with fabricated numbers.

noteTests

  • Core suite 576 passing (+4 MRR), web suite 1631 passing (+2 billing route).

v5.3.0-alpha.99

Super-admin dark console — Phase 3b (compliance cluster). Re-skins the whole compliance area from the light theme to the dark .sa-* console, finishing the admin re-skin. Additive — styling only; every query, toggle, and create flow unchanged.

noteChanged — web (super-admin)

  • Compliance index (/admin/compliance) re-skinned dark: dark stat tiles + per-tenant SLA table; added a DPO registry quick-link alongside ROPA register.
  • ROPA index + detail re-skinned dark (filters, summary tiles, stale-review flags, lawful-basis sections, toggles).
  • DPO registry index + detail re-skinned dark (scope filters, jurisdiction chips, certifications, retire action).
  • Create forms (ROPA "New activity" + "New DPO", added in alpha.97) restyled dark via a dark rebuild of the shared form kit (_form/fields.tsx) plus dark page chrome.

noteNote

  • The admin console is now fully dark across every shipped page. Remaining work is Phase 4 net-new surfaces (cross-tenant billing, incidents, performance, alerts) — these need real backends before they can be built honestly and are not stubbed.

noteTests

  • Web suite 1629 passing.

v5.3.0-alpha.98

Super-admin dark console — Phase 3a (data/triage pages). Re-skins four standalone admin pages from the old light theme to the dark .sa-* console design. Additive — every control, query, and handler unchanged; styling only.

noteChanged — web (super-admin)

  • AI costs (/admin/ai-costs) re-skinned dark: dark stat tiles, trend bars, and usage table (keeps the shared estimateAiCostUsd basis from alpha.97).
  • Promo codes (/admin/promo-codes) re-skinned dark: create form (dark inputs/radios), table, status pills, archive/restore actions — all intact, dropped the light @helpmesh/ui primitives for card-d/input-d/btn-*/pill.
  • Pages directory (/admin/pages) re-skinned dark: dark section cards, route rows, role pills, and notes banner.
  • Webhooks (/admin/webhooks) re-skinned dark: filter chips, health stat tiles, top-failing list, and the delivery-health table.

noteNote

  • Remaining light pages: the compliance cluster (/admin/compliance + ropa/dpo + the two alpha.97 create forms) re-skins next (Phase 3b, to match docs/ui-previews/compliance-center.html). Phase 4 net-new surfaces (billing, incidents, performance, alerts) still need real backends before they can be built honestly.

noteTests

  • Web suite 1629 passing.

v5.3.0-alpha.97

Super-admin gaps: compliance create forms + AI-cost estimator unification. Fills two cases where the backend existed but had no (or a broken) UI, and removes a divergent cost formula.

noteFixed — web (super-admin)

  • ROPA "New activity" was a dead link. The ROPA index linked to /admin/compliance/ropa/new, which never existed (404). Built the full GDPR Art. 30 create form (number, role, purpose, Art. 6 + Art. 9 lawful basis, data-subject / personal-data / recipient categories, retention, conditional cross-border transfer block, safeguards, DPIA + public flags) → POST /api/admin/compliance/ropa.
  • AI costs used a divergent pricing formula. The page hard-coded a straight $3/$15 per-MTok rate; every other surface uses the shared blended estimateAiCostUsd (Sonnet+Haiku). Switched the admin page to the shared estimator so the dollar figures match billing, and made the copy honest ("indicative, not authoritative invoicing").

noteAdded — web (super-admin)

  • DPO registry create. POST /api/admin/compliance/dpo had no UI; added a New DPO button on the registry and a full create form (scope, conditional tenant binding, contact details, jurisdictions, certifications editor, mandatory/public flags, appointment date).
  • Shared compliance form primitives (_form/fields.tsx) and React-free payload builders (_form/payloads.ts) covered by 11 new unit tests.

noteTests

  • Web suite 1629 passing.

v5.3.0-alpha.96

Super-admin dark-navy console — Phase 2b + start of Phase 3. Completes the tenant pages and begins the remaining re-skins.

noteChanged — web (super-admin)

  • Add-tenant wizard (/admin/tenants/new) rebuilt as the approved dark 4-step wizard (Workspace → Owner → Plan & trial → Review) with a stepper, dark inputs, live subdomain availability check, per-step gating, and a dark review + success screen. Same POST /api/admin/tenants contract — no functional change, just the wizard UX.
  • System health (/admin/system-health) re-skinned dark (status banner, database + memory/runtime cards); dropped its redundant inner page-width wrapper so it sits correctly in the console shell.

noteNote

  • Still light (next): /admin/{webhooks,ai-costs,compliance,pages,promo-codes}; net-new surfaces (billing, incidents, performance, alerts) follow in Phase 4.

noteTests

  • Web suite 1618 passing.

v5.3.0-alpha.95

Super-admin dark-navy console — Phase 2a (tenant pages + audit log). Continues the phased rebuild to docs/ui-previews/. Re-skins the most-used inner pages to the dark console; all functionality preserved (additive).

noteChanged — web (super-admin)

  • Tenant detail (/admin/tenants/:id) re-skinned dark: stat tiles, compliance + threads/billing cards, webhook health rows, recent-activity rows, the Manage SideSheet (now a dark sheet via scoped .sa-root so inputs/buttons style correctly inside the portal), inline Suspend/Reactivate, the Entitlements card, and the Danger zone delete — every control and handler unchanged.
  • Tenants list (/admin/tenants) re-skinned dark: dark grid table, status pills, region pills, search, impersonate + resend-invite actions intact.
  • Global audit log (/admin/activity) re-skinned dark: dark filter bar + rows, sensitive badge, tenant links, load-more.

noteNote

  • Remaining admin pages (webhooks, AI costs, compliance, system-health, Pages CMS, promo-codes) + the add-tenant wizard and net-new surfaces (billing, incidents, performance, alerts) re-skin in Phases 2b–4.

noteTests

  • Web suite 1618 passing.

v5.3.0-alpha.94

Super-admin dark-navy console — Phase 1 (chrome + home) + Jira webhook fix. The super-admin surface was built freehand in a light theme that didn't match the approved design (docs/ui-previews/super-admin-home.html). Phase 1 of a phased rebuild converts the chrome + home to the approved dark-navy console; all existing functionality is preserved (additive).

noteChanged — web (super-admin)

  • Dark-navy console chrome: /admin layout is now the approved left-sidebar shell (.sa-root/.sa-shell) — grouped nav (Overview / Customers / Operations / Compliance / Platform) carrying every existing admin route, dark topbar, super-admin identity card. New sa Tailwind palette + scoped .sa-* component classes in globals.css (no leakage to tenant/marketing surfaces).
  • Home ecosystem dashboard: /admin rebuilt to the dark dashboard — stat cards (active tenants, open threads, users, needs-attention), tenants-by-status + by-plan distribution panels, a recent-tenants table, and a platform-health panel. Uses real /api/admin/metrics data only (no fabricated numbers); period toggle drives the real 7d/30d signup delta.
  • GET /api/admin/metrics now also returns recentTenants (latest 6 active, with plan/status/region + member/thread counts).

noteFixed — web

  • Jira reverse-sync webhook URL now surfaced in the integration card: GET /api/v1/integrations/jira returns the full webhookUrl (secret embedded, OWNER-scoped); the card shows it with reveal + copy + setup steps. Previously the secret was generated + verified but never shown, so the webhook couldn't be wired without a DB query.

noteNote — phased rebuild in progress

  • Inner admin pages (tenant detail, audit log, tenants list, promo/AI-costs/compliance/system-health/pages) still render their prior light style inside the dark shell until their phases land. Designed-but-unbuilt surfaces (cross-tenant billing, incidents, performance, alerts, add-tenant wizard) come in later phases.

noteTests

  • Web suite 1618 passing.

v5.3.0-alpha.93

Super-admin — per-tenant entitlement overrides (feature flags). Plan capabilities (whitelabel, customDomain, sso, dedicatedRegion, socReport, prioritySupport) were fixed by plan with no per-tenant override. Added an override layer + resolver + admin API/UI so a super-admin can grant or revoke any capability for a single tenant (e.g. SSO for a sales POC). Includes a migration.

noteAdded — core (`@helpmesh/core`)

  • packages/core/src/billing/entitlements.ts — the capability registry (CAPABILITY_KEYS, CAPABILITY_LABELS), planCapabilities() (TRIAL→STARTER), and the resolver resolveEntitlement(s) (effective = override ?? planDefault). 9 tests.

noteAdded — db (migration)

  • New TenantEntitlementOverride model (@@unique([tenantId, key])) + Tenant.entitlementOverrides back-relation; added to the tenant-scope middleware set. Migration 20260607000000_tenant_entitlement_overrides (additive — new table only).

noteAdded — web

  • getTenantEntitlements(tenantId) server helper (plan + overrides → resolved map) — the single call future feature gates should use.
  • `GET /api/admin/tenants/:id/entitlements` (resolved list) · `PUT /:key` ({enabled,note?} upsert) · `DELETE /:key` (revert to plan default) — super-admin only, key validated against the registry, audit-logged (tenant.entitlement_set / _cleared). 12 tests.
  • Entitlements card on the tenant detail page: per-capability toggle, plan-default indicator, "overridden" badge, and reset-to-default. Preview: apps/web/scratch/ui-previews/admin-tenant-entitlements.html.

noteNot enforced yet (honest scope)

  • This ships the override plane + resolver. Capabilities without a runtime gate today (customDomain, sso, etc.) stay metadata until a feature adopts getTenantEntitlements — no retroactive enforcement.

noteTests

  • Core 572, web 1617 passing.

v5.3.0-alpha.92

Super-admin — cross-tenant audit activity viewer. /api/v1/system/activity is hard-scoped to the caller's active tenant (even for super-admins), so there was no way to see the platform-wide audit trail. Added a true cross-tenant viewer.

noteAdded — web (`/api/admin/activity`)

  • `GET` — super-admin-only cross-tenant audit log. Cursor-paginated; filters by tenantId, actorId, entity, action substring, sensitive (allow-list), and from/to date range. Each row includes its tenant + actor and an isSensitive flag. Unscoped via the same admin pass-through the sibling routes use.

noteAdded — web (`/admin/activity`)

  • New audit-activity page: filterable, paginated table (time · tenant · actor · action · entity) with a sensitive-action badge, an action search box, a "sensitive only" toggle, load-more pagination, and tenant cells linking to the tenant drilldown. Added an Activity entry to the super-admin nav.

noteTests

  • 7 new tests for the endpoint (403 + enrichment + cross-tenant default + action/sensitive/tenant filters + pagination). Web suite: 1605 passing.

v5.3.0-alpha.91

Super-admin — platform overview dashboard. /admin had a layout.tsx but no index page (404 on the bare path). Added a cross-tenant overview landing page + the metrics endpoint behind it.

noteAdded — web (`/api/admin/metrics`)

  • `GET` — super-admin-only cross-tenant KPIs in one round trip: tenant totals + status/plan distribution (groupBy), new-signup counts (7d/30d), active + soon-expiring trials, soft-deleted count, platform totals (users, open threads, customers), and a health rollup (disabled webhooks, pending data-deletions). Cheap counts/groupBys; unscoped via the same admin pass-through the sibling routes use.

noteAdded — web (`/admin`)

  • New Overview landing page: stat cards (tinted icon + delta chip + caps label + big number), tenants-by-status and tenants-by-plan distribution bars, a platform-health card linking to Webhooks/Compliance, and quick links. Added an Overview entry to the super-admin nav.

noteTests

  • 3 new tests for the metrics endpoint (403 + shaped-output + total-excludes-deleted). Web suite: 1598 passing.

v5.3.0-alpha.90

Super-admin — tenant lifecycle controls. The /admin/tenants/:id detail page was read-only (view + impersonate). Super-admins can now edit a tenant's lifecycle, billing, and compliance fields and soft-delete a tenant — every mutation audit-logged. No schema change (all fields already existed on Tenant).

noteAdded — web (`/api/admin/tenants/:id`)

  • `PATCH` — super-admin-only partial update of status, plan, paidSeats, trialEndsAt, auditRetentionDays, requireMfa, requireTwoAdminDelete. Zod-.strict() (unknown keys rejected), computes a changed-only before/after diff, writes an AuditLog (action: 'tenant.updated', context.actorType: 'super_admin'). No-ops when nothing changed. Region and billing identity are deliberately not editable here.
  • `DELETE` — super-admin-only soft-delete (sets status=DELETED + deletedAt; no customer data purged). Requires a type-the-slug confirmSlug and refuses with 409 HM-TENANT-ACTIVE-SUBSCRIPTION while a Stripe subscription is still ACTIVE (cancel billing first). Audit-logged as tenant.soft_deleted.

noteAdded — web (`/admin/tenants/:id`)

  • Manage button → SideSheet editor for the full field set (status, plan, seats, trial-end, audit retention, MFA + two-admin-delete toggles). Notes that plan/seat changes update HelpMesh only, not Stripe.
  • Inline Suspend / Reactivate on the Status row (one-click abuse/billing response).
  • Danger zone card with slug-confirmation delete; disabled with an explanatory note while billing is active.

noteTests

  • 16 new runtime tests for PATCH/DELETE (403/400/404/409/no-op/happy-path + audit-log + diff assertions). Web suite: 1595 passing.

v5.3.0-alpha.89

Tier-3c — Slack connect UX cleanup. Removed the dead manual-token forms; Slack now connects only via OAuth (the real path that creates an encrypted SLACK inbox). No backend change.

noteChanged — web

  • Settings → Channels: replaced the manual "Bot User OAuth Token / Channel ID / Save Manual Config" form with a single one-click Install HelpMesh into Slack OAuth button. The connected indicator now derives from an active SLACK inbox, not the legacy (dead) Tenant.settings.channelConfig.
  • Settings → Integrations: the Slack card button now launches OAuth directly; removed the dead manual-token panel, corrected the description (notifications → inbound tickets), and dropped the hardcoded "connected" badge.
  • Removed the orphaned channelConfig / slackBotToken / slackChannelId state + save/disconnect handlers that wrote to a store nothing read at runtime (same pattern as the alpha.80 Jira cleanup).

v5.3.0-alpha.88

Tier-3c — channel-disconnected indicator + Slack credentials wired. Follow-up to alpha.87.

noteAdded — worker (`channel-outbound`)

  • When an agent reply can't be delivered because a channel's credentials are missing (channel never connected / disconnected), the worker posts an agent-only SYSTEM message into the thread: "⚠️ This reply couldn't be delivered to {Slack/WhatsApp/SMS} — reconnect in Settings → Channels." systemEvent null = customer never sees it; SYSTEM authorType means the outbox fanout won't re-enqueue (no loop); tenant-scoped via runWithTenant. Renders via the existing system-message UI. 4 new tests.

noteInfra — Slack now functional in prod

  • Created helpmesh-prod/slack-client-id / -client-secret / -signing-secret in Secrets Manager and wired SLACK_CLIENT_ID / SLACK_CLIENT_SECRET / SLACK_SIGNING_SECRET into the web task def (rev 33). Slack OAuth install/callback + inbound webhook signature verification are now live. Tenants connect at Settings → Integrations → Slack.

v5.3.0-alpha.87

Tier-3c — Channel hardening (WhatsApp + Slack). Security + quality pass over the messaging channels, batched as one ship. WhatsApp is wired end-to-end (its config UI previously wrote a store the runtime never read); Slack's plaintext tokens are now encrypted at rest; both gain isolation tests; two Slack webhook correctness bugs fixed.

noteSecurity — encrypt channel secrets at rest

  • New apps/web/src/server/channels/token-store.ts (encrypt/decrypt) + apps/worker/src/lib/channel-secrets.ts (decrypt) — AES-256-GCM via the shared GOOGLE_TOKEN_ENCRYPTION_KEY, mirroring the CRM token-stores. Backward-compatible: legacy plaintext secrets (no v1: prefix) pass through and re-encrypt on next reconnect. No migration.
  • Slack OAuth callback now encrypts slackBotToken + slackIncomingWebhookUrl before persisting to Inbox.config; worker channel-outbound decrypts on read.

noteWhatsApp — real wiring (was non-functional)

  • New POST/GET/DELETE /api/v1/integrations/whatsapp (ADMIN-gated): persists phoneNumberId + encrypted accessToken to a WHATSAPP Inbox.config — the store the inbound webhook (findWhatsAppInbox) and outbound worker actually read. Replaces the legacy Tenant.settings.channelConfig path, which nothing at runtime consumed.
  • Settings → Inboxes WhatsApp card repointed at the new endpoint (connect/status/disconnect); removed the fake hardcoded phone number; access token no longer retained in the form after save.
  • Worker decrypts the WhatsApp access token before sending.

noteCorrectness — Slack webhook

  • Verify the request signature before echoing the url_verification challenge (was probe-able unauthenticated).
  • Ignore any message carrying a subtype (edits/deletes/joins/file shares) + empty/user-less messages, so they no longer spawn spurious threads.

noteAdded — tests (~22 new)

  • web: channel token-store round-trip + legacy passthrough; WhatsApp route isolation (ADMIN gate, Zod-before-write, token encryption, tenant scoping, GET never returns the token); Slack callback encryption contract; Slack webhook correctness contract.
  • worker: channel-secrets cross-surface decrypt round-trip + legacy passthrough.

noteDeferred

  • Agent-facing "channel disconnected" delivery surfacing (needs a thread system-message + UI — feature work, tracked in docs/specs/tier-3c-channel-hardening.md).

v5.3.0-alpha.86

Tier-3b/3 Salesforce — Phase 4: inbound Case→Thread poll. Final Salesforce phase, completing the bidirectional surface. A 15-minute cron reconciles Salesforce Case Status changes back onto the linked HelpMesh threads (the reverse of the Phase 2 push). No schema change — reuses the lastPolledAt column provisioned in Phase 1. Salesforce is now feature-complete (Phases 1–4). Still dormant until a Connected App is configured.

noteAdded — channels (`packages/channels/src/salesforce/contact-client.ts`)

  • listCaseStatusesByIds(accessToken, instanceUrl, caseIds, sinceIso) — SOQL SELECT Id, Status FROM Case WHERE Id IN (…) AND LastModifiedDate > <since>. Bounded by the tenant's own linked-Case ids; no query for an empty list. 401 → INVALID_SESSION.

noteAdded — worker

  • salesforce-case-poll-fanout (salesforce-case-poll-fanout.ts): every 15 min, enqueues one salesforce-case-poll per active integration (de-duped by tenant-scoped jobId), mirroring the HubSpot sync fanout. Cross-tenant scan under runWithSuperAdmin.
  • salesforce-case-poll (salesforce-case-poll.ts): per tenant, pulls the current Status of linked Cases changed since lastPolledAt (bounded at 200) and reconciles each onto its thread — only when the mapped status differs. Emits a normal thread.status_changed event in the same transaction (realtime + the other integrations propagate; the echo back to Salesforce is idempotent). Advances lastPolledAt. Deletion cleanup is left to the push path's existing 404 handling.
  • Reverse mapping mapCaseStatusToThreadStatus (salesforce-case-mapping.ts): New→OPEN, Working/Escalated→PENDING, Closed→RESOLVED; unrecognised/custom statuses → null (thread left untouched).

featureChanged

  • Registered both processors in the worker PROCESSOR_MAP + processors barrel, and added the salesforce-case-poll-fanout-cron (15-min) scheduler.

noteAdded — tests (19 new)

  • 3 channels (listCaseStatusesByIds: empty-list short-circuit, SOQL shape, 401) + 2 reverse-mapping + 9 poll isolation + 5 fanout isolation.

v5.3.0-alpha.85

Tier-3b/3 Salesforce — Phase 3: Contact/Lead + Opportunity context panel. Read-only Salesforce panel on the agent customer-detail page (left column, between Custom fields and Activity). Matches the customer to a Salesforce Contact (then Lead) by email — live, per view — and lists the linked Account's open Opportunities. No schema change; no background sync (the Phase 4 cron will cache matches later). Still dormant until a Connected App is configured.

noteAdded — channels (`packages/channels/src/salesforce/contact-client.ts`)

  • findContactByEmail, findLeadByEmail (unconverted only), listAccountOpportunities (open, ≤10, soonest close first) via the SOQL query endpoint. escapeSoql guards every interpolated value against injection. 401 → INVALID_SESSION (token store refresh + retry); other failures → SOQL_QUERY_FAILED.

noteAdded — API route (`GET /api/v1/customers/:id/salesforce`, AGENT-gated)

  • Tenant-scoped customer lookup, then a live match (Contact → Lead) + Opportunity fetch through the token wrapper. Soft-degrading discriminated response ({ enabled, linked, recordType, recordId, salesforceUrl, …record fields, opportunities, opportunitiesError }) — never 5xx's the panel just because Salesforce is misconfigured; never returns token material or another tenant's customer.

noteAdded — UI (`apps/web/src/components/agent/SalesforceContextCard.tsx`)

  • Three states matching the approved preview (docs/ui-previews/salesforce-customer-context.html): not linked / linked as Lead (status + rating) / linked as Contact (account + owner + open Opportunities with Lightning deep-links). Self-hides when Salesforce is disabled or the tenant never connected. Slotted into customer-detail below the HubSpot context card.

noteAdded — tests (18 new)

  • 10 channels (find Contact/Lead/Opportunities: mapping, null, SOQL shape, escaping, 401→INVALID_SESSION, 500→SOQL_QUERY_FAILED) + 8 route-isolation (AGENT gate, tenant scoping, soft-degrade, no token leakage).

v5.3.0-alpha.84

Tier-3b/3 Salesforce — Phase 2: Thread → Case push. Outbox-driven worker that mirrors every HelpMesh thread into Salesforce as a Case and keeps its status in sync, bridged through the provider-agnostic LinkedIssue model (provider: 'salesforce'). Same shape as the Jira Phase 2 issue-sync. No schema change. Still dormant until a Connected App is configured (SALESFORCE_ENABLED).

noteAdded — channels (`packages/channels/src/salesforce/case-client.ts`)

  • createCase (POST the Case sObject → returns the new id) and updateCase (PATCH; returns false on 404 so the caller drops a stale link). Both throw INVALID_SESSION on 401 so the worker token store refreshes + retries; config rejections surface as CASE_CREATE_FAILED / CASE_UPDATE_FAILED with the HTTP status.

noteAdded — worker

  • salesforce-case-sync processor (apps/worker/src/processors/salesforce-case-sync.ts): fires on thread.created + thread.status_changed. Creates a Case on first sight, PATCHes Status/Subject on later changes. Skip-and-log on: disconnected/revoked, record type not picked (skipped-no-config), Case deleted upstream (drops the link), 400 picklist/record-type rejection (skipped-invalid-field, with auto-clear of a deleted record type). BullMQ retry reserved for 5xx/429/network.
  • Pure mapping helpers (salesforce-case-mapping.ts): mapThreadStatusToCaseStatus (OPEN→New, PENDING→Working, RESOLVED/CLOSED/SPAM→Closed), composeCaseSubject (255-char cap), composeCaseDescription (500-char preview + deep-link), buildCaseUrl (Lightning record URL).
  • Worker token store (apps/worker/src/lib/salesforce-credentials.ts): withSalesforceAccessToken — cached token first, refresh-once-and-retry on 401 (Salesforce omits expires_in); invalid_grant marks the row revoked. No circuit breaker (BullMQ handles backoff), matching the HubSpot/Jira worker token stores.

featureChanged

  • apps/worker/src/processors/outbox.ts step 6f dispatches salesforce-case-sync alongside the HubSpot ticket + Jira issue syncs; registered in the worker PROCESSOR_MAP.

noteAdded — tests (26 new)

  • 7 channels (createCase/updateCase: success, 401→INVALID_SESSION, 404→false, 400→failure) + 7 mapping + 12 worker isolation (tenant scoping, provider-scoped LinkedIssue mutations, skip-and-log paths).

v5.3.0-alpha.83

Tier-3b/3 Salesforce — Phase 1: OAuth storage + Case pickers. First of four phases bringing Salesforce CRM to parity with HubSpot + Jira. Adds the encrypted-token storage layer, the OAuth web-server connect flow (production + sandbox), and the Settings card with the Case record-type + origin pickers. Ships dormant behind SALESFORCE_ENABLED — the card self-hides and every route 503s until a Connected App is configured. Later phases add Thread→Case push (Phase 2), the Contact/Lead + Opportunity context panel (Phase 3), and the 15-minute inbound poll (Phase 4).

noteAdded — schema (`migration 20260605120000_tier3b3_salesforce_integration`, additive)

  • New SalesforceIntegration model (one row per tenant, tenantId @unique) + SalesforceEnvironment enum (PRODUCTION/SANDBOX). Encrypted refresh + access tokens (AES-256-GCM via the shared GOOGLE_TOKEN_ENCRYPTION_KEY), per-org instanceUrl, org/user identity, granted scopes, Case sync config (caseRecordTypeId / caseRecordTypeName / defaultCaseOrigin), and telemetry watermarks.

noteAdded — channels client (`packages/channels/src/salesforce/`)

  • OAuth client: authorize-URL builder, code exchange, refresh (no token rotation), revoke, and identity fetch, all environment-aware (login vs test.salesforce.com). Single SalesforceApiError discriminated union.
  • Case describe client: one describe call returns the org's Case record types + Origin picklist values. Throws INVALID_SESSION on 401 so the token store can refresh + retry.

noteAdded — token store (`apps/web/src/server/salesforce/token-store.ts`)

  • withSalesforceAccessToken wrapper: tries the cached access token, and on a 401 INVALID_SESSION refreshes once and retries (Salesforce omits expires_in, so there's no pre-emptive expiry). Refresh gated by the shared circuit breaker, namespaced :salesforce; invalid_grant marks the row revoked.

noteAdded — API routes (all OWNER-gated except the public callback)

  • GET/PUT/DELETE /api/v1/integrations/salesforce (state / save Case config / disconnect), GET /connect (environment toggle → signed-state redirect), GET /callback (public — verifies signed state before trusting the tenant), GET /case-record-types (picker data).

noteAdded — UI

  • SalesforceIntegrationCard + SalesforceCaseSyncSection wired into Settings → Integrations (replacing the old "coming soon" tile): Production/Sandbox toggle, OAuth popup, record-type + Origin pickers. Matches the approved preview at docs/ui-previews/salesforce-integration.html.

noteAdded — tests (43 new)

  • 20 channels (OAuth client + Case describe) + 23 route-isolation (tenant scoping, OWNER gating, no token leakage in responses, verify-state-before-trust on the callback).

featureChanged

  • OAuthStatePayload gains an optional environment field so the sandbox/production choice survives the OAuth round-trip (additive; HubSpot + Jira unaffected — re-verified against the state-signer suite).

v5.3.0-alpha.82

Tier-3b/3 Phase 3: Jira webhook — Issue → Thread reconciliation. Third and final Jira phase, completing the bidirectional surface. Adds an inbound webhook endpoint that accepts Jira jira:issue_updated + jira:issue_deleted events, verifies them against the per-integration secret set up in Phase 1, and queues a reconciliation worker job that refreshes LinkedIssue.title / status or drops the link when the upstream issue is deleted. No schema change — the webhookSecret column was provisioned in alpha.80.

noteAdded — webhook route (`apps/web/src/app/api/v1/integrations/jira/webhook/route.ts`)

  • Public POST endpoint (no apiHandler wrapper — Jira doesn't send a session cookie).
  • Secret lives in the query string: POST /api/v1/integrations/jira/webhook?secret=<per-integration-secret>. Per-tenant 32-byte secret generated on connect (Phase 1).
  • Tenant resolution via runWithSuperAdmin scan of JiraIntegration rows with revokedAt: null and webhookSecret IS NOT NULL. Constant-time secret comparison via jira.verifyJiraWebhookSecret (from Phase 1).
  • Generic 401 on missing or mismatched secret (anti-probe — never leaks the failure mode).
  • Only acts on jira:issue_updated + jira:issue_deleted. Other events return 200 with ignored-event (no retry loop from Jira's side).
  • Malformed JSON → 400, not 500.
  • Job dispatch keyed on jira-issue-webhook:${tenantId}:${issueKey}:${eventType} for safe dedup of duplicate webhook fires.

noteAdded — worker processor (`apps/worker/src/processors/jira-issue-webhook.ts`)

  • Reconciles a single Jira webhook event with HelpMesh's LinkedIssue row.
  • Outcomes: updated (refreshed title + status), deleted (Jira said deleted OR our getIssue returned null), skipped-no-link (no LinkedIssue row — not our issue), skipped-not-connected (Atlassian 401 → mark revoked).
  • Tenant-scoped via runWithTenant; linkedIssue.deleteMany always scoped by (provider: 'jira', externalId) so we never accidentally drop another provider's links.
  • Idempotent: re-running for the same event re-pulls + re-writes the same fields.

noteAdded — tests (19 new)

  • route.isolation.test.ts — 10 contract tests for the inbound endpoint: public POST (no apiHandler), runWithSuperAdmin lookup, constant-time verify, generic 401, allowed-event filter, ignored-event 200, malformed-JSON 400, stable jobId for dedup, BullMQ retry semantics.
  • jira-issue-webhook.isolation.test.ts — 9 contract tests for the worker: tenant isolation, no-link skip, direct deletion shortcut, 404-race deletion, 401 → revoke, title+status refresh, scoped deletes (never cross-provider).

noteTenant action (one-time, to enable real-time updates)

After deploy, tenants who want real-time Issue → Thread reconciliation must: 1. Go to Jira → Settings → System → WebHooks → Create webhook. 2. URL: copy from the Integrations card — https://helpmesh.io/api/v1/integrations/jira/webhook?secret=<your-secret>. The secret was generated in Phase 1 and is unique per tenant. 3. Subscribe to: Issue updated + Issue deleted. 4. Set the JQL filter to your sync project (e.g. project = SUP) so HelpMesh only receives events for issues we mirror. 5. Save + enable. Tenants who skip this step still get one-way Thread → Issue sync from Phase 2 — Jira-side changes just don't reflect back into HelpMesh until they wire up the webhook.

noteOut of scope (deferred indefinitely)

  • Comment sync (HelpMesh messages ↔ Jira comments). Bigger semantics question — who owns the comment, do agents get pinged when Jira commenters reply, etc. Revisit if customers ask.
  • Custom field sync (HelpMesh custom fields → Jira custom fields). Per-tenant configuration spaghetti.
  • Assignee mapping (HelpMesh user → Atlassian account). Same per-user link table problem HubSpot Phase 2.4 deferred.

v5.3.0-alpha.81

Tier-3b/3 Phase 2: Jira Thread → Issue sync worker. Second of three Jira phases. Adds the automation Phase 1 set up the encrypted storage for: when a HelpMesh thread is created or its status changes, mirror that into Jira as an issue (status changes move the issue through the configured workflow). Same outbox-driven pattern as HubSpot Phase 2.4. No schema change.

noteAdded — pure mapping helpers (`apps/worker/src/processors/jira-issue-mapping.ts`)

  • pickTransitionId(status, transitions) — case-insensitive name match against the available Jira workflow transitions. Falls back to Jira's standard 3-bucket statusCategory taxonomy (new / indeterminate / done) when name doesn't match, so tenant-renamed workflows still work. Returns null when nothing matches.
  • composeIssueSummary({publicNumber, subject})[#1234] subject, capped at 254 chars per Jira's documented summary limit.
  • composeIssueDescription({...}) — capped at 500 chars of body preview; full thread lives back in HelpMesh via deep-link.
  • buildIssueUrl(baseUrl, issueKey)${baseUrl}/browse/${issueKey} for LinkedIssue.externalUrl.

noteAdded — worker processor (`apps/worker/src/processors/jira-issue-sync.ts`)

  • Triggered by outbox dispatch on thread.created + thread.status_changed — same shape as HubSpot Phase 2.4.
  • Uses the existing LinkedIssue model (provider-agnostic) as the Thread → Jira bridge; no new schema column.
  • Skip-and-log (NOT retry) on: integration missing/revoked, project + issue-type pickers not completed, thread status maps to no available transition, issue deleted upstream.
  • Auto-recovery: 401 on any Atlassian call → mark integration revoked. 404 on transition / list-transitions → drop the LinkedIssue row so the next event re-creates.
  • Idempotent: re-running for the same thread no-ops (no status change) or PATCHes the same transition (Jira treats that as a no-op).
  • Wired into the worker dispatcher as 'jira-issue-sync'.

noteAdded — worker credential helper (`apps/worker/src/lib/jira-credentials.ts`)

  • Mirror of apps/web/src/server/jira/token-store.ts. Decrypts the API token via GOOGLE_TOKEN_ENCRYPTION_KEY, returns full JiraCredentials for outbound API calls. markJiraRevoked(tenantId) for the 401 path.

noteAdded — outbox dispatch step 6e

  • New dispatch in apps/worker/src/processors/outbox.ts — keyed on jira-issue-sync:${event.id}, 3 attempts + exp backoff. Fired alongside the HubSpot ticket-sync dispatch when an outbox event with thread.created / thread.status_changed lands.

noteAdded — tests (26 new)

  • jira-issue-mapping.test.ts — 19 tests covering name match, status-category fallback (renamed workflows), edge cases (empty transitions, mismatch), summary cap, description truncation, baseUrl normalization.
  • jira-issue-sync.isolation.test.ts — 7 contract tests: tenant isolation, every skip guard, auto-recovery on 401 / 404, LinkedIssue create/delete shape.

noteTenant action (none)

Phase 2 is automatic — tenants who picked a project + issue type in Phase 1 now see automatic Thread → Issue mirroring without any further action. Tenants who haven't picked yet see no sync (worker no-ops with skipped-no-pickers).

v5.3.0-alpha.80

Tier-3b/3 Phase 1: Jira integration — encrypted storage + connection test + pickers. First of three phased ships landing the Jira integration. This ship replaces the pre-existing plaintext-token scaffold (config stored unencrypted in Tenant.settings.integrations.jira) with a dedicated encrypted-storage table, validates credentials against Atlassian before persisting, and adds the project + issue-type picker UX. No automation yet — Phases 2 (Thread → Issue sync, alpha.81) and 3 (webhook, alpha.82) ship on top.

noteAdded — schema

  • New JiraIntegration model: id, createdAt, updatedAt, tenantId (unique), connectedByUserId, baseUrl, atlassianEmail, encryptedApiToken, projectKey, defaultIssueType, atlassianAccountId, atlassianDisplayName, webhookSecret (Phase 3 ready), lastSyncedAt, lastValidatedAt, lastUsedAt, revokedAt.
  • Migration 20260604190000_tier3b3_jira_integration — additive only. Existing tenants with the plaintext scaffold are unaffected until they reconnect.

noteAdded — Jira REST client (`packages/channels/src/jira/`)

  • validateCredentials(creds)GET /rest/api/3/myself, throws INVALID_CREDENTIALS on 401/403 so the PUT route can return a typed 422 to the UI.
  • listProjects(creds)GET /rest/api/3/project/search. Caps at 50.
  • listIssueTypes(creds, projectKey)GET /rest/api/3/issue/createmeta/{key}/issuetypes. Filters out sub-task types. Returns null on 404 (project deleted upstream).
  • createIssue(creds, input) — POST with ADF-wrapped plaintext description. Wires for Phase 2.
  • getIssue(creds, issueKey) — single-issue fetch, null on 404.
  • listTransitions(creds, issueKey) — workflow transitions for Phase 2 status mapping.
  • transitionIssue(creds, issueKey, transitionId) — POST transition, returns true/false/throws.
  • verifyJiraWebhookSecret(...) — constant-time secret comparison for Phase 3.
  • All errors funnel through JiraApiError with a discriminated code union.

noteAdded — token-store (`apps/web/src/server/jira/token-store.ts`)

  • AES-256-GCM encryption via the shared GOOGLE_TOKEN_ENCRYPTION_KEY (same trust boundary as Google/Zoom/HubSpot — no operational benefit to splitting).
  • getJiraCredentials(tenantId) — tenant-scoped read, decrypt, return JiraCredentials. Throws JiraNotConnectedError on missing/revoked.
  • markJiraRevoked(tenantId) — flips revokedAt. Routes call this on Atlassian 401 to defuse repeated failed calls.

noteAdded — API endpoints (5)

  • GET /api/v1/integrations/jira — return connection state + project/issue-type picks. Surfaces legacyPlaintextConfig: true when the old scaffold is detected so the UI renders a "Reconnect to secure your token" banner.
  • PUT /api/v1/integrations/jira — OWNER-only, Zod-validated. On credential save: validates against Atlassian FIRST (no encrypting typos), then encrypts + upserts. On picker save: just persists. Generates a fresh webhookSecret on first save (Phase 3 ready). Auto-clears legacy plaintext config after a successful new save.
  • DELETE /api/v1/integrations/jira — OWNER-only disconnect. Marks revokedAt; no upstream revoke call (Atlassian has no equivalent).
  • GET /api/v1/integrations/jira/projects — OWNER-only. 401 from Atlassian → mark revoked + return 409.
  • GET /api/v1/integrations/jira/issue-types?projectKey=X — OWNER-only. Returns 404 when project was deleted upstream.

noteAdded — UI (`apps/web/src/components/settings/JiraIntegrationCard.tsx`)

  • Matches the approved preview at docs/ui-previews/jira-integration.html. Three states: Available / Credentials form / Connected + picker.
  • Legacy-config tenants see a "Reconnect to secure your token" amber banner instead of generic "Connect".
  • Credential form: Base URL + Atlassian email + API token (with show/hide). Help text links to Atlassian's token page. "Test & save" hits the live /myself endpoint via PUT before persisting.
  • Picker section: lazy project dropdown (GET /projects), cascading issue-type dropdown (GET /issue-types?projectKey=X on change), "Sync inactive" pill until both are picked.
  • Slotted into Settings → Integrations alongside HubSpot / Google Meet / Zoom (4-card grid).
  • Removed the in-page plaintext form from apps/web/src/app/(agent)/settings/integrations/page.tsx — the new card owns the entire Jira UX.

noteAdded — tests (52 new)

  • client.test.ts — 23 HTTP-shape tests covering all 7 client functions + auth header construction + trailing-slash stripping.
  • webhook.test.ts — 5 constant-time secret-comparison tests (Phase 3 verifier ready).
  • route.isolation.test.ts (root) — 11 contract tests: OWNER gate, Zod validation, validate-before-encrypt, 422 on bad creds, webhook secret generation, legacy-plaintext signal, tenant isolation, never-echo-token.
  • route.isolation.test.ts (projects) — 7 contract tests covering OWNER gate, 401 → revoke, typed errors.
  • route.isolation.test.ts (issue-types) — 6 contract tests covering query validation, 404 → upstream-deleted signal.

noteTenant action (one-time, after Phase 1 deploy)

Existing tenants who pre-configured Jira via the scaffold will see a "Reconnect to secure your token" banner in Settings → Integrations. The plaintext token in Tenant.settings.integrations.jira keeps working for nothing (no API endpoints actually read from it anymore) but the new flow encrypts it properly. New tenants connect via the standard form.

v5.3.0-alpha.79

Tier-3b/2 Phase 2.5: HubSpot customer-context panel. alpha.72–.77 built the bidirectional Contact + Ticket sync. Phase 2.5 surfaces that context where agents work — read-only HubSpot panel on the customer-detail page showing lifecycle / company / owner / last-touch / open deals with deep-links into HubSpot. No new schema; one new OAuth scope.

noteAdded — OAuth scope expansion

  • New scope bundle HUBSPOT_OAUTH_SCOPES_PHASE_2_5: Phase 2.4 bundle plus crm.objects.deals.read. UI canary HUBSPOT_DEAL_READ_SCOPE (single source of truth — UI checks scopes.includes(HUBSPOT_DEAL_READ_SCOPE)).
  • Connect route now requests the expanded bundle on every new flow. Existing tenants reconnect once; Phase 1+2.4 functionality keeps working until they do.
  • "Last touch" intentionally comes via the existing contacts.read scope (new contact property notes_last_contacted) instead of expanding into engagement scopes. One new scope total.

noteAdded — HubSpot deal client (`packages/channels/src/hubspot/deal-client.ts`)

  • listContactDeals(accessToken, contactId) — POST to /crm/v3/objects/deals/search with an associated-contact filter, sorts by hs_lastmodifieddate DESC, caps at 10 results.
  • Pinned property whitelist (HUBSPOT_DEAL_PROPERTIES) — never all-properties, never spread upstream deal.properties into responses. PII guardrail.
  • Two new HubspotOAuthError codes: CONTACT_DEALS_FETCH_FAILED, DEAL_FETCH_FAILED.

noteAdded — contact-sync property extensions (Phase 1 worker, transparent upgrade)

  • Sweep + webhook contact pull now includes notes_last_contacted + num_associated_deals. Surfaced in Customer.metadata.hubspot.lastContactedAt + .associatedDealCount. No schema change; the metadata blob is the same column.

noteAdded — API endpoints

  • GET /api/v1/customers/:id/hubspot — AGENT-gated, soft-degrading. Returns synced metadata (cheap) + live deal fetch (when scope granted). Discriminated response shape: enabled/linked/dealScopeGranted/dealsError ∈ {scope-missing, fetch-failed, null}. Live fetch never 5xx's — failure surfaces as dealsError: 'fetch-failed' so the UI degrades gracefully.
  • GET /api/v1/customers/:id extended to include hubspotContactId in the response shape.

noteAdded — UI card (`apps/web/src/components/agent/HubspotContextCard.tsx`)

  • Matches the approved preview at docs/ui-previews/hubspot-customer-context.html. Three states: (A) not linked → "Sync now" CTA, (B) linked + deal scope missing → reconnect banner + synced metadata, (C) full context → synced metadata + open deals + deep-link.
  • Self-hides when HubSpot isn't enabled on the deployment or the tenant has never connected. No empty placeholder.
  • Slots into the customer-detail left column between Custom fields and Activity.

noteAdded — tests (25 new)

  • deal-client.test.ts — 8 HTTP-shape tests covering search request body, contact filter, error codes, empty results, PII pinned-list guardrail.
  • oauth-client.test.ts — +4 scope bundle + deal-read canary + contact-property additions tests.
  • hubspot-contact-sync.test.ts — +2 mapping tests for lastContactedAt + associatedDealCount surfacing.
  • hubspot/route.isolation.test.ts — 11 contract tests for the new context route (AGENT gate, tenant isolation, soft-degrade discriminators, deep-link URL pattern, PII pinned-list).

noteOut of scope (deferred)

  • Owner email resolution (would need crm.objects.owners.read). UI shows the numeric ownerId. Revisit in 2.6 if agents complain.
  • Engagement timeline beyond notes_last_contacted. Would need a new scope per engagement type (calls/emails/meetings/notes). Defer until customers ask.
  • Recent closed-won deals. Currently only "open deals" are pulled. Easy add when needed.

noteTenant action required (one-time per workspace)

After deploy, tenants reconnect HubSpot (second reconnect in two ships) to grant the crm.objects.deals.read scope. Phase 1 + 2.4 surfaces keep working through the transition; the customer-detail card shows an inline "Reconnect to grant deals scope" banner until they do.

v5.3.0-alpha.78

Lint + test infra hygiene. No runtime behavior change. Restores pnpm lint and pnpm test to clean exits at the repo root after pre-existing config drift; surfaces the eslint-config-next plugin set that was silently absent from apps/web.

noteFixed — lint

  • Added eslint-config-next@14.2.21 + matching peer eslint@^8.57.0 to apps/web/package.json devDeps. Without this, next lint ran against the root .eslintrc.cjs only, which couldn't resolve react-hooks/exhaustive-deps / @next/next/no-img-element rules referenced via inline disable comments — exit 1 on every CI run.
  • New apps/web/.eslintrc.json extends next/core-web-vitals + disables the noisy react/no-unescaped-entities rule (apostrophes/quotes in JSX text — handled correctly by HTML parsers; rule is widely considered too noisy).
  • Removed 3 unused imports surfaced by the new linter:
  • Refactored a 4-case redirect switch into a map lookup in apps/web/src/app/(agent)/settings/developer/page.tsx/settings/developer/page.tsx) to clear no-fallthrough (the cases worked because redirect() throws, but the linter can't see that).

- Button from [apps/web/src/app/(agent)/customers/[id]/tickets/page.tsx](apps/web/src/app/(agent)/customers/[id]/tickets/page.tsx) - AlertTriangle from apps/web/src/app/(agent)/settings/deletion-approvals/page.tsx/settings/deletion-approvals/page.tsx) - EmptyState from apps/web/src/app/(agent)/settings/widget/page.tsx/settings/widget/page.tsx)

noteFixed — test

  • 4 packages were missing per-package vitest.config.ts and inheriting the root config's include glob (packages/**/*.test.ts), which resolved relative to the package cwd and matched nothing. Result: pnpm test exited 1 on those packages with "No test files found."
  • Added per-package configs to packages/channels (170 tests now run), packages/config (13 tests), packages/events (0 tests, passWithNoTests: true), packages/integrations (0 tests, passWithNoTests).

noteVerified

  • pnpm lint — 14/14 tasks successful, 0 errors, 25 warnings (all pre-existing react-hooks/exhaustive-deps + no-img-element in unrelated UI code).
  • pnpm test — 12/12 tasks successful, 1,196 tests green across @helpmesh-io/sdk, config, db, core, auth, ai, reporting, channels, billing, worker.
  • pnpm --filter @helpmesh/web test:unit — 161 files, 1,483 tests green.
  • Combined: 2,679 unit tests green repo-wide.

v5.3.0-alpha.77

Tier-3b/2 Phase 2.4: HubSpot Thread → Ticket sync. alpha.72–.76 covered the Contact sync stack (OAuth + pull + cron + webhook). Phase 2.4 mirrors HelpMesh threads into HubSpot as Tickets, completing the bidirectional CRM surface. Every new thread creates a HubSpot ticket; every status change moves it through the picked pipeline. Existing tenants reconnect once to grant the new ticket scopes — contact sync keeps working through the transition.

noteAdded — schema

  • Thread.hubspotTicketId (nullable, unique-per-tenant) — stable link between a HelpMesh thread and its HubSpot ticket. Per-provider column (not reusing externalRef) so future Salesforce/Zendesk mirrors get their own slot.
  • HubspotIntegration.ticketPipelineId (nullable) — admin's pipeline pick. Null means ticket sync is disabled even if the OAuth scope was granted; cleared if the picked pipeline is deleted upstream.
  • HubspotIntegration.lastTicketSyncedAt (nullable) — telemetry watermark surfaced in the Integrations card.
  • Migration 20260516193000_tier3b2_hubspot_ticket_sync — additive only.

noteAdded — OAuth scope expansion

  • New scope bundle HUBSPOT_OAUTH_SCOPES_PHASE_2_4: Phase 1 scopes plus crm.objects.tickets.read, crm.objects.tickets.write, crm.schemas.tickets.read. Existing connections detect "ticket sync available" via the crm.objects.tickets.write canary; the Integrations card shows a "Reconnect to enable" banner until the tenant re-authorizes.
  • Connect route now requests the expanded bundle on every new flow.

noteAdded — HubSpot ticket client (`packages/channels/src/hubspot/ticket-client.ts`)

  • listTicketPipelines(accessToken) — enumerate the tenant's ticket pipelines.
  • getTicketPipelineById(accessToken, pipelineId) — returns null on 404 so the worker can auto-clear deleted-upstream picks.
  • createTicket(accessToken, input) — POST with optional contact association (typeId 16 = ticket→contact).
  • updateTicket(accessToken, ticketId, patch) — idempotent stage moves; returns null on 404 so the worker can drop the link.
  • Four new error codes on HubspotOAuthError: TICKET_PIPELINES_FETCH_FAILED, TICKET_PIPELINE_NOT_FOUND, TICKET_CREATE_FAILED, TICKET_UPDATE_FAILED.

noteAdded — pure mapping helpers (`apps/worker/src/processors/hubspot-ticket-mapping.ts`)

  • mapThreadStatusToStageId(status, stages) — partitions stages by metadata.isClosed === 'true' and maps OPEN→first open, PENDING→middle open, RESOLVED→first closed, CLOSED/SPAM→last closed. Returns null when the pipeline can't represent the status.
  • previewStageMapping(stages) — drives the picker UI's "stages will be mapped like this" caption.
  • mapThreadPriorityToHubspot(priority) — URGENT/HIGH → HIGH, NORMAL → MEDIUM, LOW → LOW (HubSpot has no URGENT).
  • composeTicketSubject({publicNumber, subject}) — prefixes [#1234] so agents bouncing between HelpMesh + HubSpot correlate at a glance.
  • composeTicketContent({customerLabel, channel, helpmeshUrl, firstMessageBody}) — capped at 500 chars of preview; full thread lives back in HelpMesh via deep-link.

noteAdded — worker processor (`apps/worker/src/processors/hubspot-ticket-sync.ts`)

  • Triggered by the outbox on thread.created and thread.status_changed. Loads thread + customer + integration under runWithTenant, applies guards, and either creates or updates the ticket.
  • Skip-and-log (NOT retry) on: integration missing/revoked, ticket scope missing, no pipeline picked, customer not yet linked to HubSpot, status doesn't map to any stage, pipeline deleted upstream.
  • BullMQ retry (3 attempts, exp backoff) on transient HubSpot errors.
  • Auto-recovery: 404 on getTicketPipelineById clears ticketPipelineId (reverts UI to "pick a pipeline"); 404 on updateTicket drops Thread.hubspotTicketId so the next event re-creates.
  • Job name hubspot-ticket-sync registered in the standard-queue dispatcher; deduplicated by outbox event id.

noteAdded — API endpoints

  • GET /api/v1/integrations/hubspot/ticket-pipelines — OWNER-only. Returns [{id, label, stages: [{id, label, displayOrder, metadata.isClosed}]}]. 409 if disconnected or scope missing.
  • PUT /api/v1/integrations/hubspot/ticket-pipeline — OWNER-only. Body {pipelineId}. Zod-validated. Verifies the pipeline still exists upstream before persisting (no stale picks); empty string clears the pick without disconnecting OAuth.
  • GET /api/v1/integrations/hubspot extended with ticketScopeGranted, ticketPipelineId, lastTicketSyncedAt, syncedTicketCount.

noteAdded — Integrations card UI (`apps/web/src/components/settings/HubspotTicketSyncSection.tsx`)

  • Matches the approved preview at docs/ui-previews/hubspot-ticket-sync.html. Three states: (A) scope missing → blue "Reconnect to enable" banner; (B) pipeline not picked → dropdown picker + "Enable ticket sync" CTA; (C) configured → status line + "Change pipeline" link.
  • "Reconnect" reuses the existing popup OAuth handler — Cancel-bug safe.
  • Stacks below the FREE-tier warning if both apply.

noteAdded — tests (89 new across 6 files)

  • hubspot-ticket-mapping.test.ts — 18 mapping tests covering all status/priority/stage edge cases.
  • ticket-client.test.ts — 13 HTTP-shape tests for the four ticket helpers (associations, 404 handling, error codes).
  • hubspot-ticket-sync.isolation.test.ts — 14 contract tests for the worker processor + outbox dispatch (tenant isolation, every guard, idempotency, auto-recovery, 3-attempt retry).
  • ticket-pipelines/route.isolation.test.ts + ticket-pipeline/route.isolation.test.ts — 16 route-level isolation tests (OWNER gate, tenant scope, 409 paths, Zod validation, scope gate, never-expose-tokens).
  • oauth-client.test.ts — +2 scope-bundle assertions.

noteOut of scope (deferred to alpha.78+)

  • Reverse direction (HubSpot Ticket → HelpMesh Thread).
  • HelpMesh agent → HubSpot owner mapping (tickets sync unassigned in 2.4).
  • Reply threading (HelpMesh messages → HubSpot ticket conversation timeline).
  • Phase 2.5 customer-detail panel (HubSpot card on the customer page).

noteTenant action required (one-time)

After deploy, every tenant on the Free/Starter/Pro tier needs to: Settings → Integrations → HubSpot → Disconnect → Reconnect (grant new ticket scopes at HubSpot's consent screen) → pick a pipeline. Contact sync continues working through the entire transition; ticket sync activates the moment the pipeline is picked.

v5.3.0-alpha.76

Tier-3b/2 Phase 2.3: HubSpot webhook handler. alpha.74 + .75 shipped on-demand + cron sync. alpha.76 adds the third leg: real-time updates from HubSpot pushed to us within seconds of a contact change. The 15-min cron stays in place as a safety net for missed webhooks.

noteAdded — schema

  • HubspotIntegration.hubspotPortalId is now indexed. The webhook endpoint identifies the source integration by HubSpot's portalId (NOT our internal tenantId — HubSpot has no concept of HelpMesh tenants). Without this index every webhook fire would be a table scan.
  • Migration 20260516020824_tier3b2_hubspot_portal_index — additive only (new index, no data migration).

noteAdded — HubSpot REST client

  • `getContactById(accessToken, contactId)` — targeted single-contact fetch with the locked property list. Returns null on 404 (HubSpot deleted the record between event emission and our fetch — treat as deletion).
  • `verifyWebhookSignature(input)` — HubSpot's v3 HMAC algorithm: HMAC-SHA256(secret, method+fullUrl+rawBody+timestamp), 5-min replay window, constant-time comparison. Uses Web Crypto so it works in both Node and Edge runtimes. Typed result discriminates MISSING_HEADERS / STALE_TIMESTAMP / BAD_SIGNATURE so callers can log the precise reason internally without leaking it back to HubSpot.
  • New CONTACT_FETCH_FAILED error code in the HubspotOAuthError discriminator.
  • 9 new unit tests: getContactById (fetch shape, 404→null, other-failure error), verifyWebhookSignature (happy path + 5 rejection paths including proxy-rewrite defense).

noteAdded — webhook endpoint

  • `apps/web/src/app/api/v1/integrations/hubspot/webhook/route.ts`POST, public (no apiHandler), reads raw body before parsing (HMAC covers exact bytes), verifies signature against appUrl() (NOT req.url, defends against ALB rewrites), looks up integration by portalId via runWithSuperAdmin (cross-tenant query, no tenant context on inbound webhooks), enqueues one hubspot-contact-webhook job per event. De-dup keyed on (portalId, objectId, subscriptionType, occurredAt) so HubSpot's retry storms collapse safely.
  • Responds 401 on signature failure WITHOUT echoing the verify reason (anti-probe).
  • Responds 200 with { accepted: true, queued: N } on success. ACKs fast — slow work happens in the worker.
  • 9 isolation-contract tests covering the public-route shape, raw-body-before-parse ordering, HMAC verification call site, 401-on-failure shape, cross-tenant lookup via superAdmin, de-dup jobId, response payload doesn't leak tenantId / refresh tokens.

noteAdded — worker

  • `apps/worker/src/processors/hubspot-contact-webhook.ts` — handles a single event:
  • Tenant isolation: payload carries tenantId (resolved by the webhook route from the source portalId); every prisma call runs inside runWithTenant.
  • Wired into worker's PROCESSOR_MAP as hubspot-contact-webhook.

- contact.creation + contact.propertyChange → re-fetch via getContactById + upsert through the shared upsertContact helper (same path the sweep worker uses). - contact.deletion → soft-delete the linked Customer (set deletedAt, clear hubspotContactId). - 404 from HubSpot during fetch → treat as deletion (idempotent soft-delete).

noteOperational notes

  • Migration runs via deploy.sh prod --migrate.
  • No env-var or task-def change — HUBSPOT_CLIENT_SECRET (already wired on web + worker since alpha.72+.74) is the HMAC key.
  • User must complete the HubSpot dev portal webhook subscription: in the Legacy Apps → HelpMesh app → "Webhooks" tab, set the target URL to https://helpmesh.io/api/v1/integrations/hubspot/webhook and subscribe to contact.creation, contact.propertyChange (on properties email/firstname/lastname/phone/company/lifecyclestage/hubspot_owner_id), and contact.deletion. Until that step is done, the endpoint exists but never fires — the 15-min cron from alpha.75 continues to handle sync.
  • Defers to alpha.77+: crm.objects.tickets.write scope expansion + Thread → Ticket sync (biggest remaining piece).

v5.3.0-alpha.75

Tier-3b/2 Phase 2.2: HubSpot sync — manual button + 15-min cron + status display. alpha.74 shipped the contact-pull worker but it only fired once at connect time. alpha.75 adds the ongoing-sync surface so tenants can re-sync on demand and the system keeps itself fresh automatically.

noteAdded — manual sync

  • `apps/web/src/app/api/v1/integrations/hubspot/sync/route.ts`POST (OWNER-only) endpoint. Calls enqueueHubspotContactSync({ tenantId }), returns 202 Accepted with the BullMQ job id. Idempotent (queue de-duplicates by jobId = hubspot-contact-sync:<tenantId>).
  • 8 isolation-contract tests covering OWNER gate, tenant scope, disabled short-circuit, revoked-row 409, 202 shape, queue-failure 503 path, no token leakage.

noteAdded — cron fanout

  • `apps/worker/src/processors/hubspot-sync-fanout.ts` — cross-tenant fanout. Single findMany (runWithSuperAdmin-wrapped because there's no tenant context for a cron), one queue add per active integration. Per-tenant failures don't abort the loop.
  • Registered on the low queue with a 15-minute scheduler (hubspot-sync-fanout-cron).

noteAdded — UI feedback on the integration card

  • `apps/web/src/app/api/v1/integrations/hubspot/route.ts`GET response now includes syncedContactCount (cheap count of Customer WHERE tenantId = ? AND hubspotContactId IS NOT NULL, indexed).
  • `apps/web/src/components/settings/HubspotIntegrationCard.tsx` — new "Sync now" button (RefreshCw icon, spins while queueing). Last-synced line displays "Last synced 5m ago · 142 contacts" using a relative-time formatter. Polls the status endpoint 8 s after a manual trigger so the contact count visibly updates without a page reload.

noteNote

  • No schema change. No migration.
  • Worker task-def :4 (set in alpha.74) already had the env wiring; nothing to update.
  • Defers to alpha.76: webhook handler for upstream live updates. Defers to alpha.77+: crm.objects.tickets.write scope expansion + Thread → Ticket sync.

v5.3.0-alpha.74

Tier-3b/2 Phase 2.1: HubSpot Contact → Customer pull worker. Tenants who connect HubSpot now see their CRM contacts populate as HelpMesh customers automatically. The OAuth callback enqueues an initial sync; the worker pages through HubSpot's contact-search API, upserts each contact into the Customer table (linked via the new hubspotContactId column), and advances the lastSyncedAt high-water mark. Out of scope for alpha.74: manual "Sync now" button, cron polling, webhook handler — all queued for alpha.75+.

noteAdded — schema

  • `Customer.hubspotContactId String?` + @@unique([tenantId, hubspotContactId]) + @@index([hubspotContactId]). Per-provider column rather than reusing externalId so future Salesforce/Zendesk sync workers can layer in without collision.
  • Migration 20260516012526_tier3b2_hubspot_contact_link — additive only: new nullable column + new unique constraint + new index. Existing customers unaffected until a worker run links them.

noteAdded — HubSpot REST client

  • `packages/channels/src/hubspot/oauth-client.ts`listContactsModifiedSince(accessToken, sinceMs, after?) wraps POST /crm/v3/objects/contacts/search with the hs_lastmodifieddate >= since filter, ASC sort (deterministic high-water-mark advancement), and the locked property list (email, firstname, lastname, phone, company, lifecyclestage, hubspot_owner_id, hs_lastmodifieddate).
  • Added CONTACT_SEARCH_FAILED to HubspotOAuthError.code discriminator.
  • 5 new unit tests covering request shape, no-filter-on-first-sync, cursor pagination, response passthrough, and the new error code.

noteAdded — worker

  • `apps/worker/src/lib/hubspot-token.ts` — worker-side mirror of the web token store. AES-256-GCM via shared GOOGLE_TOKEN_ENCRYPTION_KEY, refreshes on demand, auto-marks revokedAt on invalid_grant. No circuit-breaker (BullMQ's retry+backoff already handles the upstream-flaky case).
  • `apps/worker/src/processors/hubspot-contact-sync.ts` — processes the hubspot-contact-sync job: pages through modified contacts (capped at 5 pages = ~500 contacts per job), upserts each as a Customer, advances lastSyncedAt. Idempotent (re-running same window is a no-op), tenant-scoped on every prisma call.
  • `apps/worker/src/processors/hubspot-contact-mapping.ts` — pure helpers (composeName, mergeHubspotMetadata) extracted so unit tests don't drag in the token-store/prisma dependency chain.
  • 9 new unit tests covering name composition (whitespace, missing sides), metadata merging (namespace preservation, defensive non-object handling, re-sync overwrite, null fallbacks).
  • Match strategy: existing-by-hubspotContactId → update in place; existing-by-email with no link → adopt; otherwise create fresh row. Never steals a customer already linked to a different HubSpot id.

noteAdded — web app

  • `apps/web/src/lib/hubspot/sync-queue.ts`enqueueHubspotContactSync({ tenantId }) adds a job to the standard queue, de-duped by jobId = hubspot-contact-sync:<tenantId> so back-to-back enqueues collapse.
  • `apps/web/src/app/api/v1/integrations/hubspot/callback/route.ts` — after the upsert, fires enqueueHubspotContactSync({ tenantId }). Failure is logged + swallowed so an enqueue glitch doesn't break the connect flow.

noteOperational notes

  • Worker task-def `helpmesh-prod-worker:4` registered with the four HubSpot env entries (HUBSPOT_ENABLED + HUBSPOT_CLIENT_ID + HUBSPOT_CLIENT_SECRET + GOOGLE_TOKEN_ENCRYPTION_KEY) — the web task-def was wired in alpha.72 already.
  • Migration applies via --migrate. Additive only.
  • Phase 2.2 (alpha.75): manual sync button + cron poll every 15 min. Phase 2.3 (alpha.76): webhook handler with HMAC verification. Phase 2.4 (alpha.77+): ticket-write scope expansion + Thread → Ticket sync.

v5.3.0-alpha.73

Branding preview iframe + HubSpot OAuth popup UX. Two small follow-ups discovered while smoke-testing alpha.72 on prod: the branding settings page's live-preview pane was rendering blank, and HubSpot's "Cancel" button on the consent screen was taking users to www.hubspot.com instead of returning them to HelpMesh.

noteFixed — Branding live preview was blank

  • `apps/web/next.config.mjs` — the global security headers were setting X-Frame-Options: DENY + CSP frame-ancestors 'none' for every path, including /preview/branding. The branding settings page tries to embed /preview/branding?surface=help as a same-origin iframe and was blocked.
  • Scoped a more permissive override at /preview/:path*: X-Frame-Options: SAMEORIGIN + CSP frame-ancestors 'self'. Other routes keep the strict global headers.
  • Refactored the CSP construction to share directives between the strict (global) and same-origin (preview) variants — only frame-ancestors differs.

noteFixed — HubSpot "Cancel" navigated away from HelpMesh

  • `apps/web/src/components/settings/HubspotIntegrationCard.tsx` — the Connect button now opens the OAuth flow in a centered popup window (600×760). HubSpot's Cancel button on the consent screen navigates the popup to www.hubspot.com and the main HelpMesh tab is preserved. If the popup is blocked, falls back to a same-tab navigation so the existing ?hubspot=<status> callback handling still works.
  • `apps/web/src/app/api/v1/integrations/hubspot/callback/route.ts` — the callback HTML now branches at runtime: if window.opener is present, post a hubspot-oauth-result message to the opener (with the status string) and window.close(). Otherwise fall back to the original meta-refresh redirect to /settings/integrations?hubspot=<status>.
  • The integration card now listens for the postMessage and reloads its connection state in-place — no full-page reload, no flicker.

noteNote

  • Pure UI / config / client-side wiring. No schema, no migration, no API contract change.
  • HubSpot's Cancel behavior is non-spec-compliant (real OAuth servers should redirect_uri with error=access_denied). The popup is a defensive wrapper around HubSpot's UX choice; if HubSpot ever fixes this, the popup pattern still works.

v5.3.0-alpha.72

Tier-3b/2: HubSpot CRM integration — Phase 1 (read-only). Tenants can now connect a HubSpot portal from Settings → Integrations and HelpMesh persists the OAuth 2.0 token pair (AES-256-GCM encrypted) for use by the upcoming sync workers. Phase 1 ships the connect / disconnect surface and the typed REST client only; bidirectional sync ships in alpha.73+.

noteAdded — OAuth flow

  • `packages/channels/src/hubspot/oauth-client.ts` — REST client with buildAuthorizationUrl, exchangeCodeForTokens, refreshAccessToken, revokeToken, fetchAccountInfo. Maps HubSpot's invalid_grant response onto a typed HubspotOAuthError so callers can mark the integration revoked. Phase 1 scopes locked at oauth + crm.objects.contacts.read + crm.objects.companies.read.
  • `packages/channels/src/hubspot/oauth-client.test.ts` — 11 unit tests covering authorize-URL shape, token-exchange body format (form-encoded with client creds in body, NOT HTTP Basic — different from Zoom), refresh-token rotation, revoke 200/204/404 handling, access-token introspection with tier fallback.

noteAdded — token store + refresh circuit

  • `apps/web/src/server/hubspot/token-store.ts` — mirrors server/zoom/token-store.ts. getValidHubspotAccessToken(tenantId) decrypts the cached access token, refreshes when within 60 s of expiry, persists the rotated refresh token (HubSpot rotates on every refresh — same as Zoom), and auto-marks revokedAt on invalid_grant. Shares the refresh circuit-breaker with Google + Zoom, namespaced as ${tenantId}:hubspot so a HubSpot outage doesn't open the circuit for the other providers.

noteAdded — API routes

  • `apps/web/src/app/api/v1/integrations/hubspot/connect/route.ts`GET (OWNER-only) signs a 10-minute state token and 302s to HubSpot's /oauth/authorize.
  • `apps/web/src/app/api/v1/integrations/hubspot/callback/route.ts` — public, verifies the signed state, exchanges code for tokens, calls fetchAccountInfo (token introspection + tier endpoint), upserts the HubspotIntegration row with encrypted refresh + access tokens.
  • `apps/web/src/app/api/v1/integrations/hubspot/route.ts`GET returns connection state (portal id, account email, tier, scopes, last-used) for the integration card. DELETE (OWNER-only) best-effort revokes upstream then marks revokedAt locally.
  • `apps/web/src/app/api/v1/integrations/hubspot/route.isolation.test.ts` — 7 contract tests asserting apiHandler + tenant-scoping on every prisma.hubspotIntegration call, OWNER guard on DELETE, no encrypted-token leakage in the response shape, revoked rows treated as not-connected.

noteAdded — Settings UI

  • `apps/web/src/components/settings/HubspotIntegrationCard.tsx` — Connect / Disconnect card with HubSpot orange (#FF7A59), Connected as ... display from hubspotAccountEmail, FREE-tier warning chip, callback-status messages mapped from the ?hubspot=... query param.
  • `apps/web/src/app/(agent)/settings/integrations/page.tsx` — mounted the new card alongside Google Meet + Zoom; removed the dead coming-soon HubSpot tile from the providers grid.
  • `apps/web/src/app/(agent)/settings/layout.tsx` — added Integrations entry to the settings sub-nav under the Developer section (Plug icon, above API keys). Every integration card the page hosts — Slack, Zapier, Jira, Webhooks, Google Meet, Zoom, HubSpot — was previously only reachable by typing /settings/integrations directly.

noteOperational notes

  • Requires HUBSPOT_ENABLED=true + HUBSPOT_CLIENT_ID + HUBSPOT_CLIENT_SECRET + GOOGLE_TOKEN_ENCRYPTION_KEY injected via ECS task-def. Web task-def helpmesh-prod-web:30 registered with these entries; secrets stored at helpmesh-prod/hubspot-client-id + helpmesh-prod/hubspot-client-secret. IAM execution role (helpmesh-prod-ecs-execution) already covers the path.
  • Migration 20260515073000_tier3b2_hubspot_integration (groundwork commit 6e1c9d7) applies on this deploy via --migrate. Adds the HubspotIntegration table + HubspotAccountTier enum, fully additive.
  • Phase 2 (alpha.73+): bidirectional sync workers (Contact → Customer, Thread → Ticket), webhook handler for upstream changes, ticket-write scopes added to OAuth consent.

v5.3.0-alpha.71

FIX-4 + FIX-5: customer portal new-ticket polish + profile-page stale stubs replaced with real CTAs. Closes out the last two items in the buyer-demo FIX queue. FIX-1 through FIX-6 now all shipped.

noteFixed — FIX-4: customer portal `/portal/new`

  • `apps/web/src/app/(customer)/portal/new/page.tsx` — made the Subject field required (min 3 chars, both client validation + submit-button guard) so agents stop receiving titleless body-walls.
  • Replaced the hardcoded "Our team typically responds within 2 hours" footer with generic "We'll reply by email and you'll see updates here in the portal." The 2-hour promise was untrue for any tenant whose SLA differs from the default. Per-tenant response-time copy will surface once /api/widget/v1/sessions exposes the inbox SLA target (separate follow-up).

noteFixed — FIX-5: agent profile page stale stubs

  • `apps/web/src/app/(agent)/settings/profile/page.tsx` — the Security section had three "Read only" / "Coming soon" badges (Password / MFA / Active sessions) that all shipped real on /settings/security back in FIX-1 (alpha.62). Replaced the dead badges with Change password → / Manage MFA → / View devices → Link buttons that route to the actual surfaces.
  • Replaced the dead Generate personal token (coming soon) disabled button with a new "API access" section that links to /settings/developer (where tenant API keys already work). Description acknowledges that per-user tokens are still on the roadmap rather than pretending they're days away.

noteNote

  • Pure UI / copy / routing changes. No schema, no migration, no API contract change.
  • Removes three stale TODO(PR-E) / TODO(PR-F) comments from the profile page.

v5.3.0-alpha.70

FIX-3: onboarding "Connect your channels" was a fake toggle. Step 2 of the new-workspace wizard had three cards (Email / Live Chat Widget / Slack) with "Connect" buttons that only flipped a local React state. No inbox was created, no widget enabled, no Slack OAuth — buyer reached /inbox empty and asked "where do tickets come from?" The "Support Email" input below it had no onChange and no submit path.

fixFixed

  • `apps/web/src/app/(agent)/onboarding/page.tsx` — replaced the fake handleConnect() toggle (and connectedChannels local state) with three <Link> cards that route to the real setup surfaces:
  • Removed the dead Support Email input field. Real email setup lives on the dedicated settings page where Postmark verification, domain check, and forwarding-address generation actually happen.

- Email → /settings/channels/email (Postmark inbound forwarding) - Live Chat Widget → /settings/widget (embed script + branding) - Slack → /settings/integrations/slack (OAuth) Each card shows a "Set up →" affordance with an external-arrow icon. Subcopy clarifies "You can come back here anytime."

noteNote

  • Honest UX. No more green "Connected" badges for things that aren't connected.
  • Tier-3 onboarding restyle preserves the wizard shell, progress stepper, and Branding / Team / Ready steps unchanged.

v5.3.0-alpha.69

FIX-2: marketing /knowledge page no longer redirects to /login. /knowledge was incorrectly added to PROTECTED_PREFIXES in alpha.35's auth-gate hardening — it was meant to cover only the agent-facing surfaces (the KB editor lives at /settings/kb, not /knowledge). Result: every "Knowledge Base" marketing CTA, every link from /features or the blog, every AI-crawler hit to /knowledge bounced through /login?callbackUrl=/knowledge — buyer demo dead-end.

fixFixed

  • `apps/web/src/middleware.ts` — removed /knowledge from PROTECTED_PREFIXES. The marketing page at apps/web/src/app/(marketing)/knowledge/page.tsx (Open Graph + JSON-LD enabled, listed in the AI-crawler carve-out per anti-scraping.md) now serves 200 to anonymous visitors. Tenant-facing public articles live under /[tenantSlug]/articles/[slug] and were never affected by this prefix.
  • `apps/web/src/__tests__/middleware-auth-gate.test.ts` — updated assertions: /knowledge and /knowledge/anything now expected false in the public-surface group, dropped from the protected-surface group. Test count unchanged at 9.

noteNote

  • Pure middleware fix. No schema, no migration, no API change.
  • Surfaces this affected: every link to /knowledge, the public-features OG image / share link, indexed search engine results pointing at the page.

v5.3.0-alpha.68

FIX-6: dashboard KPI empty state. Fresh workspaces showed a mix of 0 (count metrics) and (derived metrics) across the KPI strip — read as "real data, all zero" instead of "no data yet." Buyer-audit gripe.

fixFixed

  • `apps/web/src/app/(agent)/home/page.tsx` — gated COUNT-style KPIs through a single isEmptyWorkspace = totalThreadCount === 0 signal, reusing the same value that already drives the onboarding checklist (no new fetch). Applied across all four KPI surfaces:
  • Once the first thread arrives, totalThreadCount flips positive and every card reverts to live numbers. Derived metrics (avg response, CSAT, SLA) keep their existing inline != null em-dash paths unchanged.

- TeamKpis: Open tickets value + subtitle + sparkline em-dash when empty - AgentKpis: My open tickets + Awaiting first reply em-dash + subtitle swap - Admin "Your queue" (inline, uses analyticsMine): My open tickets + My new today + My pending replies em-dash + subtitle swap - ViewerKpis (historical view): all four count cards (Total tickets, This month, Resolved, Resolution rate) em-dash when historical.totalAllTime === 0

noteNote

  • Pure presentation change. No new endpoint, no schema change, no extra fetch.
  • Sparkline is hidden in the empty state (not a flat baseline) — a sparkline of zeros reads as misleading.

v5.3.0-alpha.67

OAuth error UX: dead-end "Server error" page replaced with a friendly login banner. After signing in with Google and reaching the onboarding step, hitting the browser Back button replays the OAuth callback URL. The PKCE state cookie has already been consumed, so NextAuth throws InvalidCheck: pkceCodeVerifier value could not be parsed and falls through to its default /api/auth/error?error=Configuration page — a dead-end card saying "There is a problem with the server configuration. Check the server logs for more information" with no path forward. Same trap fires for CallbackRouteError, AccessDenied, and any future auth error.

fixFixed

  • `packages/auth/src/config.ts` — added pages.error: '/login' so NextAuth routes every auth error back to the login page with ?error=<code> instead of its stock "Server error" card.
  • `apps/web/src/app/(auth)/login/page.tsx` — read ?error=<code> via useSearchParams and feed it into the existing error state. New describeAuthError() helper maps NextAuth codes (AccessDenied, OAuthAccountNotLinked, CredentialsSignin, SessionRequired, default) to user-readable messages. Raw codes are never echoed — they leak implementation details.

noteNote

  • Pure UI/config polish. No schema, no migration, no API change.

v5.3.0-alpha.66

Google OAuth fix for new accounts. PrismaAdapter v2 calls prisma.user.create({ data: { emailVerified, image, ... } }) on first OAuth sign-in. Our User schema renamed those columns to emailVerifiedAt and avatarUrl (predates NextAuth). Result: PrismaClientValidationError: Unknown argument 'emailVerified' → AdapterError → "Server error / There is a problem with the server configuration" page for every brand-new Google account. Existing OAuth users were unaffected because the adapter only calls findUnique on them.

fixFixed

  • `packages/auth/src/config.ts` — added a helpmeshUserAdapter() wrapper around PrismaAdapter(prismaUnscoped). Overrides createUser and updateUser to translate emailVerifiedemailVerifiedAt and imageavatarUrl on the way in, and back on the way out. Reads (getUser*, linkAccount) flow through untouched — NextAuth treats the row's extra fields as opaque user data.

noteNote

  • Cheaper than renaming columns: a global rename would force a migration + every call-site rewrite across the repo. Adapter-boundary translation keeps the schema stable.
  • No new tests added — existing 21 auth tests still pass. Adapter behaviour to be verified by user dogfooding Google sign-in with a brand-new account post-deploy.

v5.3.0-alpha.65

Three FIX-1 follow-throughs: unblock CI, break the layout-redirect loop, fix the signup CTA.

fixFixed

  • `packages/auth/src/config.ts` — moved the HASH_SECRET resolution + production-only throw out of module scope into a lazy getHashSecret() called by deviceFingerprint and ipHash. Next.js' next build "Collect page data" step imports every route module to read its runtime config; a module-level throw killed the entire build even though no request was being served. Every staging + production CI run has been failing since alpha.62 because of this — local deploys via scripts/deploy.sh were unaffected (Docker BuildKit cache reused a layer that pre-dated the throw). The throw still fires at request time when the env var is genuinely missing.
  • `apps/web/src/app/(agent)/layout.tsx` — when useSession().status === 'unauthenticated', the alpha.64 redirect called router.replace('/login') but left the stale session cookie in place. Edge middleware on /login saw hasSessionToken() === true and bounced the user back to /inbox, so client-side navigation appeared to "do nothing." Switched to signOut({ callbackUrl }) so the cookie is cleared before the redirect; added a useRef guard to keep React's effect re-runs from queueing parallel signOut calls.
  • `apps/web/src/app/(auth)/login/page.tsx` — "Start a free trial" was <a href="mailto:hello@helpmesh.io"> (legacy stub from before signup was built). Pointed at /signup like every marketing CTA. next/link was already imported.

noteNote

  • No schema changes, no migration. Pure code-only redeploy.
  • Outstanding: Google OAuth prisma.user.create() fails for brand-new users (existing Google accounts work because PrismaAdapter findUniques them). Separate User-schema issue, will ship as its own alpha.

v5.3.0-alpha.64

FIX-1 follow-through: revoked-session redirect. After clicking "Sign out all other sessions" on device A, device B was stuck on a degraded shell — empty sub-nav, missing avatar, default-OFF settings — until the user manually navigated. The session() callback was correctly rejecting B's JWT (tokenVersion guard worked server-side), but nothing on the client took action on the resulting useSession() === 'unauthenticated' state. Edge middleware can't catch this because it only checks cookie presence, not JWT validity against Postgres.

fixFixed

  • `apps/web/src/app/(agent)/layout.tsx` — added a useEffect that calls router.replace('/login?callbackUrl=...') when useSession().status flips to 'unauthenticated'. Self-corrects on the next NextAuth session-poll cycle (~30s) or immediately on hard refresh. Closes the loop on FIX-1's sign-out-everywhere UX.

noteNote

  • Pure UI change. No schema, no migration, no API surface changes.

v5.3.0-alpha.63

Hotfix on top of FIX-1: HASH_SECRET fallback now accepts the env names prod actually injects. alpha.62 throws Auth package requires IP_HASH_SECRET or NEXTAUTH_SECRET in production at module import because the ECS task definitions use the canonical NextAuth v5 name (AUTH_SECRET) and the pre-existing IP_HASH_SALT infra secret — neither of which alpha.62's new code read. Every request returned 500.

fixFixed

  • `packages/auth/src/config.ts` — widened the HASH_SECRET cascade to IP_HASH_SECRET ?? IP_HASH_SALT ?? NEXTAUTH_SECRET ?? AUTH_SECRET. Matches what every other module in the repo already accepts (apps/web/src/lib/impersonation.ts, apps/web/src/app/api/v1/integrations/*/route.ts, apps/web/src/lib/env-secrets.ts). Throw message updated accordingly.

noteNote

  • No schema changes, no migration. Code-only redeploy.
  • Pre-landing review miss: auth-package tests don't run under NODE_ENV=production, so the module-import throw was invisible until prod boot. Follow-up to add a NODE_ENV=production smoke test for the auth package import.

v5.3.0-alpha.62

FIX-1 — Real per-device session tracking on /settings/security. Replaces the seeded "Chrome on macOS, Dubai" demo data that the Security page rendered when the missing sessions endpoint 404'd. The security panel now reflects what's actually authenticated against your account, and "Sign out everywhere" + change-password actually kill every JWT we ever issued for the user.

featureAdded

  • `UserDevice` model + `User.tokenVersion` + `User.passwordLastChangedAt` (additive migration 20260514183304_fix1_user_device_tracking). One row per (userId, fingerprint). User-scoped (a User can belong to multiple tenants), not tenant-scoped.
  • `events.signIn` callback in @helpmesh/auth: upserts one UserDevice per sign-in (Credentials + OAuth) using a stable SHA-256 fingerprint over UA + Accept-Language + per-user salt. IPs are hashed with SHA-256 + per-user salt per data-handling.md — raw IPs are never stored. UA truncated to 256 chars for forensics. Sign-in always succeeds even if device tracking errors.
  • `GET /api/v1/users/me/sessions` — returns the current user's active devices ordered by lastSeenAt, with the row matching the current request's fingerprint marked isCurrent: true.
  • `DELETE /api/v1/users/me/sessions` — "Sign out everywhere." In one transaction: increments User.tokenVersion (the session() guard rejects every JWT carrying the old version) and marks every non-current UserDevice row revoked. The caller is also logged out and bounces to /login.
  • `tokenVersion` guard in the NextAuth session() callback: invalidates any JWT whose embedded tokenVersion is older than the current User.tokenVersion. Backward-compatible — tokens issued before this version have no embedded version and are auto-bumped on next sign-in.
  • Exposed helpers from @helpmesh/auth: deviceFingerprint, deviceLabel, ipHash (all SHA-256, all per-user-salted, all truncated for storage).

featureChanged

  • `POST /api/v1/auth/change-password` — replaced the no-op Session.deleteMany() (the Session table is empty under JWT strategy) with real session invalidation: tokenVersion++, passwordLastChangedAt = now, mark every UserDevice revoked, all in a single transaction. UI now redirects to /login after the response. Argon2 imports swapped to the existing @helpmesh/auth hashPassword / verifyPassword wrappers — removes the dev-only Function('return import("argon2")') indirection that broke under Next 14 dev mode ESM resolution.
  • Security page UI — removed the Potemkin "Chrome on macOS, Dubai" fallback. Empty state now renders "No active sessions." instead of fake demo rows. Added formatLastActive() relative-time helper. "Sign out all other sessions" redirects to /login because the tokenVersion bump kills the current device's JWT too.
  • `@helpmesh/auth` — added next as a peerDependency so import { headers } from 'next/headers' resolves cleanly. The package only uses next/headers inside the events.signIn request-context block.

securitySecurity

  • Sign out everywhere is now real. Bumping User.tokenVersion invalidates every active JWT for that user at the session() guard, not just one cookie.
  • Forced rotation on password change. Changing your password now invalidates every device in one transaction (forensic-grade audit trail via revokedAt timestamps).
  • No raw IPs stored. Per data-handling.md, IPs are PII under GDPR — UserDevice.ipHash is a 24-char SHA-256 prefix with a per-user salt.

noteTest coverage

  • End-to-end verified on the dev stack (5/5 pass): credentials sign-in resolves session; GET sessions returns the real device row marked isCurrent; second sign-in collapses to the same row id (upsert correctness); DELETE sessions bumps tokenVersion and the next /api/auth/session returns null; POST change-password returns 200, bumps tokenVersion, sets passwordLastChangedAt, revokes devices, and invalidates the current JWT.

v5.3.0-alpha.61

Integrations tier-3b/1 — Customer tags + auto-rules + security consolidation. First of 3 sub-PRs splitting the original tier-3b (15-18d → 3 bounded ships). Ships variants 4 + 14. Tier-3b/2 (HubSpot) and tier-3b/3 (Salesforce + Jira) reuse this PR's tag surface for CRM property sync.

noteSchema (additive migration)

  • New Tag model: tenant-scoped, soft-delete tombstone, locked palette color, free-form group for UI clustering. Unique (tenantId, key). key is immutable after creation.
  • New CustomerTag join: tenant-scoped, (customerId, tagId) unique. appliedByRuleId attribution distinguishes manual edits from rule firings.
  • New TagRule model: tenant-scoped, JSONB condition validated by Zod, trigger enum (PERIODIC ships now; EVENT lands in tier-3b/3). matchedCount + lastEvaluatedAt backfilled by the evaluator.
  • TagKind enum (PLAN / AUTO / MANUAL) + TagRuleTrigger enum.

noteZod (@helpmesh/core)

  • TagKeySchema enforces lowercase kebab-case, max 60 chars, starts with letter. Stable identifier referenced by automation rules + (future) CRM sync.
  • TagRuleConditionSchema is a discriminated union over op (count_gt/_gte/_lt, avg_lt/_gt, no_activity_days_gt, plan_tier_in). Closed source enum (thread/csat/customer/plan). Hard caps: numeric value 0..100_000, windowDays 1..365, windowCount 1..50, plan_tier_in.value ≤ 20.
  • UpdateTagSchema omits key and kind — Zod silently strips client attempts; immutability is the security model.
  • TAG_LIMITS constants: 200 tags + 50 active rules per tenant + 4-hour evaluator cadence + reserved PLAN keys (vip/enterprise/growth/standard).

noteAPI (10 new routes)

  • GET /api/v1/tags — list with customerCount inline (groupBy)
  • POST /api/v1/tags — ADMIN — create MANUAL tag; rejects reserved keys with 422
  • PATCH /api/v1/tags/:id — ADMIN — update (label/color/description/group); rejects PLAN with 422
  • DELETE /api/v1/tags/:id — ADMIN — soft-delete tombstone; rejects PLAN with 422
  • POST /api/v1/tags/:id/apply/:customerId — AGENT+ — manually tag a customer; rejects PLAN+AUTO with 422
  • DELETE /api/v1/tags/:id/apply/:customerId — AGENT+ — untag; same PLAN+AUTO rejection
  • GET /api/v1/tag-rules — list rules
  • POST /api/v1/tag-rules — ADMIN — create rule; promotes MANUAL → AUTO on first rule attachment; rejects PLAN target with 422
  • PATCH /api/v1/tag-rules/:id — ADMIN — update; honors the active-rule cap on flipping isActive
  • DELETE /api/v1/tag-rules/:id — ADMIN — hard delete (rules aren't customer data)

noteWorker

  • New tag-rule-evaluator processor sweeps every 4 hours (TAG_LIMITS.evaluatorIntervalMinutes). For each active PERIODIC TagRule:
  • Skips rules whose target tag is tombstoned. Closed-shape Prisma queries — no $queryRaw / $executeRaw.

1. Defensive TagRuleConditionSchema.safeParse(rule.condition) (drift guard — drift-detected rules log + continue rather than auto-disable). 2. Resolve into a closed-shape Prisma query (no raw SQL paths) for each source × op combination. 3. Diff matched customers vs currently-applied appliedByRuleId = rule.id rows. 4. Add missing / remove orphaned in a single per-rule prisma.$transaction; emit one customer.tag_applied / customer.tag_unapplied OutboxEvent per change. 5. Bump rule.matchedCount + rule.lastEvaluatedAt.

noteUI

  • /settings/tags (NEW) — three stat tiles, tag groups (PLAN rendered read-only), and the auto-rules card. Closed-set condition builder modal with 4 concrete shapes (thread volume / CSAT avg / no-activity days / plan-tier in) mapped to the closed-enum server schema via serializeCondition.
  • /settings/security (modified) — adds two nav cards at the top (IP allowlist + SSO) with anchor IDs so cross-page deep links resolve here. Full visual consolidation deferred to a tier-3b/1b polish PR; this delivers the user-visible "one place to go for security" outcome without rewriting the existing 575-line page.
  • Settings sidebar gets a "Tags" entry under "Content & Tagging" between Labels and Knowledge base.

noteOpenAPI

  • 2 new response schemas (Tag, TagRule) + 10 new route registrations.

noteTests (+97 net)

  • Core: +34 schema tests (regex, hard caps, discriminated-union narrowing, immutable-key + immutable-tagId guards)
  • Web: +46 isolation tests across 5 route files (~9 source-introspection assertions each)
  • Worker: +17 tests (matchesNumeric pure-function + 11 source-introspection on the evaluator transaction shape)
  • Net totals: core 563 + web 1443 + worker 97 = 2103 passing

noteLocked decisions (from scope §4 + §6)

  • PLAN tags are auto-derived from billing. Reserved keys reject user creation with 422.
  • `Tag.key` is immutable after creation. UpdateTagSchema omits key; client attempts are silently stripped at the Zod boundary.
  • PLAN + AUTO tags reject manual apply/unapply with 422. The evaluator owns AUTO rows, billing sync owns PLAN.
  • MANUAL → AUTO auto-promotion when first rule attaches. Evaluator becomes source of truth.
  • Cross-tenant lookups return 404, not 403. No information disclosure.
  • Caps: 200 tags + 50 active rules per tenant. Evaluator at 4-hour cadence.

noteDeferred to tier-3b/2 + tier-3b/3

  • HubSpot (tier-3b/2): OAuth + webhook receiver + sync queue + Tag ↔ HubSpot property mirror.
  • Salesforce + Jira (tier-3b/3): reuses HubSpot scaffolding; wires Automation.conditions to read customer.hasTag(); activates EVENT-trigger TagRule sweeps.
  • Variant 4 full visual consolidation: nav cards + anchors ship now; the stacked single-page section layout from the preview is a polish PR.

noteRefs

v5.3.0-alpha.60

Reports tier-3c (Custom Report Builder). Closes the Reports tier-3 wave (4a + 4b + 4c). Tenants can now compose their own reports without engineering involvement — three-pane builder (field library / canvas / live preview), persistent saved-report library, scheduled-PDF delivery, and read-only share links.

noteSchema (additive migration)

  • New SavedReport model: tenantId, name, definition (JSONB validated by Zod), schedule (enum-mapped preset), scheduleRecipients, shareToken + shareTokenIssuedAt + shareTokenExpiresAt, pinned, createdById, deletedAt + deletedById (tombstone soft-delete). Unique on (tenantId, name). Indexes on (tenantId, deletedAt), (tenantId, pinned), (tenantId, schedule). No backfill.

noteZod (@helpmesh/core)

  • ReportDefinitionSchema with closed enums for source (threads/agents/csat), chart (bars/line/area/donut/table/big-number), dimension field, measure aggregator, filter op. Hard caps: dimensions ≤ 3, measures 1..5, filters ≤ 10. Custom date-range validates the 90-day span at the union level.
  • CreateSavedReportSchema, UpdateSavedReportSchema, SetScheduleSchema, ShareTokenIssueSchema.
  • SAVED_REPORT_LIMITS constants pin the 30-day share lifetime, dual rate-limit (10/min/token + 30/min/IP), and 3-failure auto-disable threshold.
  • SCHEDULE_PRESET_TO_CRON maps the 4 fixed presets to cron expressions server-side — no raw cron from input.

noteAPI (12 new routes)

  • GET /api/v1/reports/saved — pinned-first list, cursor pagination
  • POST /api/v1/reports/saved — create (AGENT+)
  • GET /api/v1/reports/saved/:id — read one (404 for cross-tenant)
  • PATCH /api/v1/reports/saved/:id — update (creator OR ADMIN)
  • DELETE /api/v1/reports/saved/:id — soft-delete tombstone
  • POST /api/v1/reports/saved/:id/preview — read-only live composition
  • POST /api/v1/reports/saved/:id/share — ADMIN — issue/rotate share token (returns token ONCE)
  • DELETE /api/v1/reports/saved/:id/share — ADMIN — revoke
  • POST /api/v1/reports/saved/:id/schedule — ADMIN — set schedule + recipients (validated against tenant membership)
  • DELETE /api/v1/reports/saved/:id/schedule — ADMIN — clear schedule
  • POST /api/v1/reports/saved/:id/pdf — enqueue one-off PDF render
  • GET /api/v1/public/reports/:shareToken — no auth, token-only; crypto.timingSafeEqual compare; dual rate-limit; 410 on expiry, 404 on revoked/unknown; renders tenant branding (logoUrl + brandingTokens.primaryColor)

noteWorker

  • New scheduled-report processor on a 5-minute sweep. Reads SavedReport rows with non-null schedule whose preset's mapped cron lands in the current sweep window, emits report.pdf_requested OutboxEvent into the existing report-pdf-generate processor for fanout. Auto-disables a schedule after 3 consecutive failures in a 24h window (matches Phase W webhook auto-disable pattern, scaled down because cron firings are rarer).

noteUI

  • /reports/builder — three-pane builder (field library left, canvas center, live preview right). Click-to-add field pills (keyboard-first; no drag-drop needed for a11y or completeness). Chart-type segmented control (bars/line/area/donut/table/big-number). Below 1024px shows "Open on desktop to edit" empty state.
  • /reports/builder/:id — edit existing saved report. Server-side resolves the row via runWithTenant before hydrating the client.
  • /reports/saved — pinned-first index with inline pin/unpin/delete. EmptyState CTA links straight to /reports/builder.
  • /r/:token — public read-only viewer. Tenant branding ON (Q3 locked decision): renders tenant logo in header, primary color as header border-bottom, HelpMesh footer attribution non-removable.
  • /reports — header gets "+ New report" + "My reports" CTAs (single-import touch).

noteOpenAPI

  • 12 new route registrations + 2 new response schemas (SavedReport, SavedReportShareToken) in packages/core/src/openapi/registrations.ts. /api/v1/openapi will surface the real shapes post-deploy.

noteTests (+112 net)

  • Core: 42 schema tests covering enum boundaries, hard-cap rejections, date-range 90-day span, filter injection-guard regex.
  • Web: 56 isolation tests across 7 route files (source-introspection regex over each handler — fast, no DB, catches contract regressions).
  • Worker: 14 tests against shouldFireNow cron matching + source-introspection on the failure-counting + auto-disable transaction shape.
  • Net totals: core 529 + web 1397 + worker 80 = 2006 passing.

noteLocked decisions (from scope §14)

  • Share-token lifetime 30 days (Q1 lock).
  • Schedule presets 4 fixed — Mon 9am, Fri 5pm, monthly 1st 9am, daily 8am — no raw cron from input (Q2 lock).
  • Public share renders tenant branding ON (Q3 lock).
  • Public route rate-limit 10/min/token AND 30/min/IP combined (lower bucket wins; Q4 lock). IP hashed with per-tenant salt before storage per data-handling.md.

noteRefs

v5.3.0-alpha.59

Tier-3 PR-11 (Integrations tier-3a, variants 10+11) — Google Meet + Zoom pages, plus the carry-over widget UI from alpha.58. Closes the Integrations tier-3a bundle (8/8 surfaces shipped). Combined ship because alpha.58's services-stable rollout never ran (see incident note below) — the widget bundle + UI from PR #116 deploy alongside this release.

noteSchema (additive migration)

  • Tenant.videoDefaults Json @default('{}'). Single typed column shared by both new pages — each form surfaces the subset of fields relevant to its provider, missing keys fall through to schema defaults so it's safe for either page to write only its provider's settings. No backfill.

noteZod (@helpmesh/core)

  • New VideoDefaultsSchema with .strict(). Fields: defaultProvider (google-meet/zoom/last-used), defaultDurationMinutes (15/30/45/60), meetUseAgentCalendarBusy, meetSendReminderEmail, meetAutoShareRecording, zoomWaitingRoom, zoomRequirePasscode, zoomAutoRecord (off/cloud/local).
  • Default provider locked to google-meet per feedback_video_provider_default.

noteAPI

  • New GET /api/v1/integrations/google-meet/agents — one row per active membership tagged with Google connection state. Today only the OAuth initiator is connected (per-tenant schema); the endpoint is shaped for the eventual per-agent flow so the UI doesn't need to change when that lands.
  • New GET /api/v1/integrations/zoom/scopes — Zoom granted-scopes list. Per feedback_zoom_granular_scopes — Zoom split create/update/delete into separate scopes; UI disables matching actions for missing scopes. Returns needsReconnect: true when granted scopes exist but a required one is missing.
  • Extended PATCH /api/v1/tenant with a videoDefaults branch (mirrors BrandingTokens + WidgetConfig pattern: validate, replace-not-merge into JSON column).
  • Extended GET /api/v1/tenant to select videoDefaults.

noteUI

  • /settings/integrations/google-meet — header with gradient 'M' logo + 'Default video provider' chip when enabled · 3 stat tiles (placeholders pending events pipeline) · connected agents list (X-of-Y counter, per-row Connected/Pending pill) · default behavior form (provider default + duration + 3 Meet toggles).
  • /settings/integrations/zoom — granular-scopes warning banner · 'Reconnect required' banner (only when needsReconnect) · Marketplace app card · granted scopes list with Granted/Missing-required/Optional pills · default behavior form (waiting room, passcode, auto-record).
  • Both pages keep their form-row + toggle patterns inline rather than extracting shared components prematurely. Each page < 250 LoC.

noteWidget carry-over from alpha.58 (now finally rolling)

  • /settings/widget rewrite (3-tile stat strip + dark embed-code panel + configuration card + per-locale welcome editor + live preview iframe).
  • apps/web/public/widget.js rebuilt to 15.38 KB (5.40 KB gzip) with previewMode + 'remount' command additions.
  • apps/web/public/widget-preview.html static page mounting the production widget bundle in preview mode.

noteTests

  • 11 new VideoDefaultsSchema input contract assertions.
  • 4 new source-string tests pinning the route wiring.
  • 6 new isolation contract assertions on /google-meet/agents.
  • 7 new isolation contract assertions on /zoom/scopes.
  • 1341/1341 passing across the full apps/web suite (24 net new from this PR; widget tests already counted in alpha.58).

noteProduction incident (alpha.58 deploy, 2026-05-13 ~19:30 UTC)

  • The alpha.58 deploy of PR #116 (widget) ran with --migrate and partially succeeded: the intended widget_config migration applied to prod RDS, but a second migration (20260513232628_video_defaults) was inadvertently included in the docker image because Dockerfile COPY . picked up an in-flight migration directory from the next branch's working tree.
  • The unintended migration's hand-trim was missed; it tried to re-create an index that already exists in prod and failed with relation "ApiKey_deprecatedAt_idx" already exists.
  • Migration task exited 1 → deploy.sh aborted before rolling the new image. Prod app continued serving alpha.57 (no user-visible impact).
  • Recovery: cleared the failed _prisma_migrations row via a one-off ECS task running prisma migrate resolve --rolled-back 20260513232628_* (exit code 0 confirmed in CloudWatch).
  • This release's migration is 20260513232743_video_defaults (different timestamp, clean SQL — drift hand-trimmed); the widget_config migration is already applied so prisma migrate deploy skipped it.
  • Gotcha captured in memory: feedback_dockerfile_copy_in_flight_branch.md. Future-fix candidates: git stash in deploy.sh before docker build, or use git archive HEAD | docker build - so the docker context is the committed tree.

noteGates

  • Web typecheck: clean
  • Full repo typecheck: 15/15 packages clean
  • Web tests: 1341/1341
  • Web lint: 7 pre-existing errors / 0 new
  • CI (PR #117): 8m54s green

v5.3.0-alpha.58

Tier-3 PR-10 (Integrations tier-3a, variant 1) — Chat widget redesign + Shadow-DOM live preview. Schema add + Zod schema + PATCH branch + page rewrite + a real (not fake-mock) Shadow-DOM iframe preview rendering the production widget bundle in the new previewMode.

noteSchema (additive migration)

  • Tenant.widgetConfig Json @default('{}') — replaces the legacy settings.widgetXxx catch-all keys (widgetPosition, widgetPrimaryColor, widgetWelcomeMessage), which keep working until tenants resave through the new typed shape. No backfill.

noteZod (@helpmesh/core)

  • WidgetConfigSchema with .strict() — unknown keys rejected, not silently stripped. Defaults pre-fill so the widget bootstrap is robust to tenants who have never saved through the new shape.
  • Fields: position (bottom-right / bottom-left / custom-anchor), launcherColor (#hex, optional, falls through to brandingTokens), launcherIcon (chat / help / headset / mail), showOnMobile, preChatFields (max 4), hiddenOnPaths (max 50), welcomeMessages (per-locale dict).

noteAPI

  • Extended PATCH /api/v1/tenant — new widgetConfig branch mirrors the BrandingTokens pattern: Zod-validate, replace-not-merge into the JSON column.
  • Extended GET /api/v1/tenant — selects widgetConfig so the page hydrates without a second round-trip.
  • ADMIN-gated (no privilege regression).

noteWidget bundle (apps/widget → apps/web/public/widget.js, 15.38 KB / 5.40 KB gzip)

  • New previewMode config flag — when true, skips api.configure(), analytics.track(), and api.createSession() so admins can preview without polluting their inbox or counting against analytics.
  • New 'remount' command — tears down the prior host element and re-inits cleanly. Used by the preview iframe so config edits propagate live.
  • Bundle grew 322 bytes (5.06 → 5.40 KB gzipped). New branches are guarded behind if (config.previewMode) so production callers pay no extra runtime cost.

noteUI

  • /settings/widget rewrite: 3-tile stat strip (placeholders pending events pipeline) + dark embed-code panel with copy button + configuration card (position seg-tabs + launcher color picker with 'Reset to brand' + 4 built-in icons + show-on-mobile toggle + pre-chat field chips + hide-on-paths textarea) + per-locale welcome message editor + live preview iframe.
  • apps/web/public/widget-preview.html — tiny static page that loads /widget.js, decodes ?config= for first paint, then listens for postMessage({ type: 'helpmesh-preview-config', config }) from the parent. Uses 'remount' to apply each update cleanly. iframe sandbox='allow-scripts allow-same-origin'.

noteVerified end-to-end locally (browse-driven)

  • Initial render with ?config= query: widget mounts in bottom-right with the supplied color and greeting.
  • postMessage({ position: 'bottom-left', launcherColor: '#16a34a', greeting: '…' }): launcher hops to bottom-left, header + CTA + close button + link color all switch to green, greeting re-renders. No console errors.

noteTests

  • 11 new Zod input contract assertions covering all enums, hex shorthand, array caps, .strict() rejection of unknown keys, and welcomeMessages shape.
  • 4 new source-string tests pinning the route wiring (import, safeParse call, directFields.widgetConfig write, GET+PATCH select).
  • 1317/1317 passing across the full apps/web suite (15 net new).

noteDecision log

  • Per the locked memory cap on Shadow-DOM preview (§6.3 of the kickoff plan), the fallback was supposed to be the existing fake-mock WidgetSurface. Spiked the real iframe with no time cap (per user direction) and it worked first try — keeping it. The previewMode + remount widget changes are the foundation for a future per-tenant live-preview embeddable.

noteGates

  • Web typecheck: clean
  • Widget typecheck: clean
  • Web tests: 1317/1317 passing
  • Widget bundle: 15.38 KB (5.40 KB gzip), under the 16 KB ceiling
  • Web lint: 7 pre-existing errors / 0 new

v5.3.0-alpha.57

Tier-3 PR-9 (Integrations tier-3a, variant 15) — Custom-fields entity tabs + redesign. Schema extend + 4 routes touched + 13 isolation tests (first this surface has had) + page rewrite to match the locked preview (EntityTabs, Visible-to-customer toggle, Default value, encryption banner).

noteSchema (additive migration)

  • CustomFieldEntity enum (THREAD / CUSTOMER / COMPANY). THREAD is the default — every existing row stays a ticket field; no backfill needed.
  • CustomField.entity (default THREAD).
  • CustomField.isCustomerVisible (default false) — controls whether the field renders in the customer-facing portal.
  • CustomField.defaultValue (nullable text).
  • CustomField.validation (Json {} default) — separate from the existing options json so per-type validation (regex, min/max length, numeric bounds) stays cleanly separated from SELECT enum options.
  • @@index([tenantId, entity]) — powers the ?entity= filter and the EntityTabs query path.

noteAPI

  • Extended GET /api/v1/custom-fields — optional ?entity=THREAD|CUSTOMER|COMPANY filter validated against the enum allow-list before reaching Prisma. orderBy is now [{ entity }, { position }] so each EntityTab sorts independently.
  • Extended POST /api/v1/custom-fields — accepts entity, isCustomerVisible, defaultValue (nullable), validation. Position is computed per-entity (not global) so adding a CUSTOMER field doesn't shift THREAD positions.
  • Extended PATCH /api/v1/custom-fields/:id — accepts the same four fields. defaultValue uses the destructure + if (defaultValue !== undefined) pattern so a client can clear the field via PATCH { defaultValue: null }.
  • No new endpoints — the four existing routes (GET, POST, PATCH, DELETE) cover the full surface.

noteUI

  • /settings/custom-fields redesign: EntityTabs above the list (Tickets / Customers / Companies, with per-entity counts). Switching tabs filters client-side and seeds the entity field on new-field creation.
  • Table layout: FIELD (name + monospace key) / TYPE (color badge) / REQUIRED / VISIBLE TO (Agent only vs Agent + Customer) / actions.
  • Field editor pane: grid-2 layout with Name / Type chips / Required toggle / Visible-to-customer toggle / Default value on the left, Options builder on the right (only when type=SELECT).
  • Honest encryption banner: "AES-256-GCM via AWS KMS" — never "your encryption key" (per locked memory). Notes that values are redacted in audit logs by default.

noteTests

  • 7 isolation contract assertions on list/create.
  • 6 isolation contract assertions on detail/update/delete.
  • First isolation tests this surface has ever had. Custom-fields routes had zero coverage before this PR — gap closes here.

noteDeferred to follow-up PRs

  • FILLED % column (preview shows it). Thread-level field values live on Thread.customFieldValues JSON, so computing fill rate requires scanning every Thread's JSON for the key — not cheap at scale. Lands once we decide whether to materialize a counter.
  • Drag-to-reorder mutation. Grip handles render but the position-swap endpoint isn't wired (existing per-entity position integer is honored on read).

noteGates

  • Web typecheck: clean
  • Web tests: 1302/1302 passing (13 new)
  • Web lint: 7 pre-existing errors / 0 new

v5.3.0-alpha.56

Tier-3 PR-8 (Integrations tier-3a, variant 13) — Labels redesign + soft-merge. Schema extend + 1 new endpoint + 2 extended endpoints + page redesign with LabelMergeModal and per-row 30-day usage badge.

noteSchema (additive migration)

  • Label.description (TEXT, optional) — surfaced in the labels picker tooltip and on the labels admin row.
  • Label.mergedIntoId (self-FK, ON DELETE SET NULL) + Label.mergedAt — soft tombstone left by the merge endpoint. Kept ~90d so audit logs + compiled automation references stay readable; future cleanup job hard-deletes them.
  • @@index([tenantId, mergedIntoId]) for the default 'hide tombstones' query path.

noteAPI

  • Extended GET /api/v1/labels?includeMerged=true to see tombstones (default off). Returns per-row usage30d computed via a single prisma.threadLabel.groupBy keyed by labelId — cheap, revisit at thousands-of-labels scale.
  • Extended POST /api/v1/labels — accepts description.
  • Extended PATCH /api/v1/labels/:id — accepts description (nullable so the client can clear).
  • New POST /api/v1/labels/:id/merge-into/:targetId — single transaction:

1. Drop ThreadLabel duplicates (threads that already have both labels) 2. Re-point remaining ThreadLabel rows to the target 3. Tombstone the source (mergedIntoId + mergedAt) 4. Emit OutboxEvent label.merged 5. Write AuditLog row with source/target pairing Cross-tenant defense via two tenant-scoped findFirst lookups (404 if either is missing). Self-merge → 400. Already-merged → 409 HM-CONFLICT-ALREADY-MERGED.

noteUI

  • /settings/labels redesign: description editor inline on each row, usage30d badge, "Show merged" toggle.
  • New LabelMergeModal — pick target label, confirm impact (count of threads being re-pointed), single-click merge with optimistic feedback.

noteTests

  • 7 isolation contract assertions on list/create.
  • 8 isolation contract assertions on the merge endpoint (tenant pinning, 404-not-403, self-merge 400, already-merged 409, atomic single-tx with all 3 prisma writes + outbox + audit, threadLabel duplicate handling).

noteGates

  • Web typecheck: clean
  • Web tests: 1268/1268 passing (15 new)
  • Web lint: 7 pre-existing errors / 0 new

v5.3.0-alpha.55

Tier-3 PR-7 (Integrations tier-3a, variant 12) — Canned responses redesign. Schema extend + 3 API surfaces + page redesign with scope/locale visibility model.

noteSchema (additive migration)

  • New CannedScope enum (TEAM | PERSONAL).
  • Five additive columns on CannedResponse: scope (default TEAM keeps existing rows visible to the whole tenant), userId (nullable FK to User, ON DELETE SET NULL), locale (free-form), usageCount, lastUsedAt.
  • Two new composite indexes for picker queries.
  • User.personalCannedResponses back-relation.

noteAPI

  • Extended GET /api/v1/canned-responses?scope=TEAM|PERSONAL|ALL + ?locale=. PERSONAL rows are only visible to the calling user (never bleed across agents). Search composes via AND so the visibility OR never widens scope. Orders by (lastUsedAt desc, name asc) for recency-first picking.
  • Extended POST /api/v1/canned-responses — accepts scope/locale; PERSONAL requires a signed-in caller; duplicate shortcut still returns 409.
  • Extended PATCH /api/v1/canned-responses/:id — accepts scope/locale (locale nullable so the client can clear it).
  • New POST /api/v1/canned-responses/:id/record-use?count=N — atomic increment + lastUsedAt update. WHERE pins both tenantId + id (no cross-tenant bump). 404-not-403 cross-tenant shape. count bounded to [1, 20]. Designed for composer batching (5s debounce per scope §4.3 Q14).

noteUI (`/settings/canned-responses`)

  • Scope filter tabs at the top: All / Team / Personal.
  • Optional locale dropdown (auto-populated from distinct locales in result set).
  • Per-row badges: ScopePill (blue Team / violet Personal), LocaleChip for non-null locales, usage line ("Used 42 times · 2h ago") when usageCount > 0.
  • Create/edit form: Visibility seg-toggle + optional Locale text input.

noteTests

  • 21 new isolation contract assertions across 3 test files (list/create, [id] PATCH/DELETE, record-use). Web suite 1253 → 1274 passing.

noteGates

  • Web typecheck: clean
  • Web tests: 1274/1274 passing
  • Web lint: 7 pre-existing errors / 0 new

v5.3.0-alpha.54

Tier-3 PR-6 (Integrations tier-3a, variant 5) — Webhooks page stat-card strip. Pure presentation add to /settings/webhooks. Per scope §6.4 + Phase W lock: **zero touches to /api/v1/webhooks/* routes**.

noteUI

  • 4-tile rolled-up stat strip above the webhook list using the locked dashboard stat-card style (tinted icon + caps label + big number).
  • Tiles: Active endpoints / Deliveries (7d) / Success rate (7d, tone-coded emerald ≥ 95% / amber 80–95% / red < 80%) / Auto-disabled (red when > 0).
  • Data computed client-side from the existing GET /api/v1/webhooks response — no extra fetch.
  • Strip is hidden when the tenant has zero webhooks (preserves the existing empty-state UX).
  • WebhookCard, RevealedSecretBanner, CreateWebhookModal, SigningDocs — untouched.

noteGates

  • Web typecheck: clean
  • Web tests: 1253/1253 still passing (no new tests — pure presentation; existing route tests cover the data shape)
  • Web lint: 7 pre-existing errors / 0 new

v5.3.0-alpha.53

Tier-3 PR-5 (Integrations tier-3a, variant 6) — Zapier landing page. First surface in the Integrations tier-3 cadence. Read-only composition over existing endpoints — no schema, no new APIs.

noteUI

  • NEW page at /settings/integrations/zapier.
  • Header card with Zapier branding + "Open in Zapier" CTA.
  • 3 stat tiles: Active Zaps (inferred), Live API keys, Total webhooks.
  • Webhook URL panel for Zapier triggers with copy-to-clipboard.
  • API keys panel (read-only mask via GET /api/v1/api-keys), links to /settings/api-keys for management.
  • Active Zaps list — filtered from GET /api/v1/webhooks where url matches the hooks.zapier.com host. Per scope §4.3 Q8, we do NOT enumerate Zaps via the Zapier API (fragile, requires a separate Zapier app integration); the link-out to the user's Zapier dashboard covers anything not visible from our webhook subscriptions.
  • Templates grid — 3 link-out cards to Zapier-hosted templates.

noteGates

  • Web typecheck: clean
  • Web tests: 1253/1253 still passing (no new tests — pure presentation over endpoints already covered by their own isolation tests)
  • Web lint: 7 pre-existing errors / 0 new

v5.3.0-alpha.52

Tier-3 PR-4b (Reports tier-3b) — `/reports/agents/[id]` agent detail + `/settings/audit-log` redesign. Adds the deep per-agent view alongside the alpha.51 Overview redesign, plus the long-awaited audit-log compliance treatment (sensitive-action allow-list, compliance summary tiles, detail drawer with BEFORE/AFTER diff).

noteSchema (additive migration)

  • Thread.@@index([tenantId, firstResponseAt]) — powers the median FRT measure.
  • Thread.@@index([tenantId, assigneeId, createdAt]) — powers the per-agent workload chart + activity heatmap aggregations.
  • SLAPolicy.atRiskPercent Int @default(25) — per-policy override threshold. UI editor at /settings/sla-policies is deferred; the field ships harmless until consumed by a future variant 5 PR.

noteAPI

  • New GET /api/v1/reports/agents/[id] (N2) — per-agent: stats, 30-day workload, CSAT distribution, resolution histogram (6 buckets), low-CSAT feed, working-hours pass-through, hour-of-week activity heatmap (Redis 1h cache). All thread reads pin tenantId; CSAT joins via thread.assigneeId. Returns 404 (not 403) for cross-tenant access. ?days bounded 7..180.
  • Extended GET /api/v1/system/activity?sensitive=true narrows the result to actions on the new SENSITIVE_AUDIT_ACTIONS allow-list. Every row now carries a server-side isSensitive: boolean flag driving the SensitiveBadge.

noteCore

  • packages/core/src/data/sensitiveAuditActions.ts — 13-action allow-list (PII deletion, impersonation, AI-safety overrides, content rollback, real-PII test sends) drawn from real codebase action names. isSensitiveAuditAction() O(1) check.

noteUI

  • /reports/agents/[id] — new page with 6 new components in apps/web/src/components/reports/agent-detail/: WorkloadChart (recharts BarChart), CsatDistributionList, ResolutionHistogram (recharts BarChart), ActivityHeatmap (plain SVG, UTC-bucketed), WeeklySchedule (read-only render of User.workingHours), OOOEmptyState (current-OOO stopgap + "history coming soon" empty state).
  • /settings/audit-log — adds 4 compliance summary tiles (on-screen / sensitive / distinct actors / last 7d), "Sensitive only" toggle, SensitiveBadge on rows, and replaces the inline expand-JSON with a AuditDetailDrawer (SideSheet) that renders BEFORE/AFTER key-by-key. Existing row layout preserved.

noteTests

  • packages/core: 5 new unit tests on the sensitive-action allow-list (487/487 passing).
  • apps/web: 10 new isolation contract assertions on N2 + 3 new on /system/activity (1253/1253 passing).

noteDeferred to follow-up PRs

  • Variant 2 list redesign at /reports/agents (currently a tab inside /reports).
  • Variants 4/5 standalone routes (CSAT trends + SLA compliance pages).
  • Variant 6 custom report builder (tier-3c, has SavedReport schema).
  • OutOfOfficeWindow schema + per-window OOO history.
  • /settings/sla-policies editor for the new atRiskPercent field.

v5.3.0-alpha.51

Tier-3 PR-4 (Reports tier-3a, variant 1) — `/reports` Overview redesign + 3 new endpoints. Tighter shippable slice of the Reports tier — the Overview tab gets the locked dashboard stat-card treatment (tinted icon + delta chip + sparkline) and three new read-only API routes power the new tiles. The other 4 tabs (Agents / SLA / CSAT / Meetings) and the route split are deferred to tier-3b/c.

noteAPI

  • Extended GET /api/v1/reports/overview — now accepts ?days=N alongside ?from=&to=, adds ?compareWith=prior_period (per-KPI percent deltas against the immediately preceding window), adds sparklines.* (5 evenly-spaced buckets per KPI), and computes a real csatAverage (was hard-coded null).
  • New GET /api/v1/reports/anomalies — z-score on weekly thread volume per channel + per label, ≥2σ threshold + MIN_CURRENT_COUNT=5 to suppress trivial spikes. Returns top 3 outliers ranked by absolute z-score. Powers the Smart Suggestions tile.
  • New GET /api/v1/reports/csat/distribution — rating histogram (1–5) with ?groupBy=overall|channel|agent.
  • New GET /api/v1/reports/csat/comments — cursor-paginated feed of CSATResponse rows with non-null comment, filterable by minRating/maxRating/free-text search. Minimal join surface (id + name on customer + assignee, no body leak).

noteUI — Overview tab redesign in place

  • 4 KPI tiles via new KpiSparkTile component (tinted icon top-left + delta chip top-right + caps label + huge number with unit + 5-bar SVG sparkline bottom-right). Avg-response-time tile uses invertDelta so "down" reads as good.
  • Volume area chart + Channel donut row.
  • 3-col bottom row: Top labels bar + LatestCsatList 4-row preview + SmartSuggestionsTile (gradient hero card reading from /anomalies, loading / empty / populated states).
  • "Investigate & draft KB article" CTA stubs to /inbox?search=<label> — the KB-draft AI flow is deferred per scope §6.8.

noteTests

  • 5 new isolation test files (35 contract assertions) for the 4 routes — tenant scoping, Zod validation, no-leak select patterns, threshold constants, default-deny enum handling.
  • Web suite 1217 → 1252 passing.

noteDeferred to follow-up PRs

  • Route split (/reports/agents, /reports/csat, /reports/sla, /reports/meetings).
  • Variants 2/4/5 deep redesigns + variant 3 (/reports/agents/[id]) + variant 7 (audit log redesign).
  • N6–N10 endpoints (saved reports CRUD + aggregator + share token — tier-3c).
  • 2 new CSV exports (CSAT + audit log with streaming + 100k cap).

v5.3.0-alpha.50

Tier-3 PR-3b — Knowledge base agent UI. Three variants land together against the alpha.49 API contract: the /settings/kb list redesign, the new /settings/kb/structure drag-reorder canvas, and the /settings/kb/[articleId] editor rewrite. The release also closes a gap left by alpha.49 by shipping the missing POST /api/v1/kb/ai-jobs route — the single-article AI assist entry point the editor right-rail dispatches against.

note`/settings/kb` — list redesign (variant 1)

  • AI Suggestions panel polls GET /api/v1/kb/ai-jobs?status=succeeded&limit=10 every 30s; per-row Accept → POST /ai-jobs/:id/accept; Dismiss → POST /ai-jobs/:id/dismiss.
  • Bulk-action bar (publish / unpublish / archive / translate) reveals on multi-select; calls POST /api/v1/kb/articles/bulk directly.
  • Translate opens a locale-picker popover then dispatches action: 'translate' with targetLocales[].
  • New collection filter alongside status + locale.
  • Honours feedback_no_shortcuts.md: no visible kbd chips.

note`/settings/kb/structure` — NEW route (variant 2)

  • Collection-level drag-reorder via @dnd-kit/sortable. No cross-collection drag in v1 (scope §7).
  • Reads alpha.49 GET /collections enriched shape (articleCount, healthScore, lastUpdatedAt, iconKey, visibility).
  • Optimistic local reorder PATCHes /collections/reorder and reverts on 409.
  • Per-collection more_vert menu surfaces "Suggest a split" → POST /collections/:id/split-suggestion (gated to ≥15 articles).
  • Three stat tiles (collections / total articles / avg health) at the top, health colour-coded green/amber/red against 85/60 thresholds.

note`/settings/kb/[articleId]` — editor redesign (variant 3)

  • Full-bleed editor layout with top bar (status pill + autosave indicator + Preview / Revisions / AI assist / Publish) and a right rail (Publishing + SEO + Versions sections).
  • Autosave (2s debounce) via PATCH /articles/:slug?asDraft=true — only title + body + autosavedAt mutate per the alpha.49 route comments.
  • Cheap client-side conflict guard: before every save (autosave or explicit) GET the article and compare updatedAt against the cached value; on mismatch surface "edited elsewhere — reload" toast and abort.
  • Revisions side-sheet lists revisions from GET /revisions; "Save current as version" calls POST /revisions for an explicit snapshot.
  • AI right-rail dispatches the 4 scope §4.3 actions (rewrite-title, summarize, translate, tone-check) against the new POST /api/v1/kb/ai-jobs route.
  • New Tiptap extensions: Underline (already present), Strike, Image (https-only prompt), Table (3x3 default, header row, no resize). Editor init dynamically imports all extensions in parallel — no SSR cost.

noteSchema preflight

  • Article.@@unique([tenantId, slug, locale]) — replaces the old (tenantId, slug) constraint so the same slug can ship across en + ar + fr. Migration 20260513040000_article_slug_locale_unique (additive: drops old constraint, adds new). Safe on live data because no existing tenant has same-slug-different-locale rows yet.

noteNew API route

  • POST /api/v1/kb/ai-jobs (NEW, ADMIN-gated) — single-article enqueue used by the editor right-rail. Mirrors the bulk-translate + split-suggestion pattern: ArticleAiJobCreateSchema validation, route-level superRefine that translate requires targetLocale, ownership check on articleId via tenantId, single row in queued status, BullMQ fan-out via enqueueArticleAiJob.

noteTests

  • packages/core/__tests__/kb-bulk-action.test.ts (8 cases) — locks KbBulkActionSchema against the bar's payload shapes.
  • packages/core/__tests__/kb-collection-reorder.test.ts (5 cases) — locks KbCollectionReorderSchema against the canvas's payload shapes.
  • packages/core/__tests__/kb-ai-job-create.test.ts (8 cases) — locks ArticleAiJobCreateSchema against the 4 right-rail kinds + edges.
  • apps/web/.../kb/ai-jobs/route.isolation.test.ts (+6 cases) — locks the new POST route's ADMIN gate, Zod validation, translate-locale enforcement, tenant-scoped ownership, queued status, BullMQ enqueue.
  • apps/web/src/lib/kb/__tests__/editor-schema-compat.test.ts (17 cases) — pre-flight ProseMirror schema compatibility for the new Image + Table extensions.

Repo typecheck clean (15/15). Core tests 490/490. Web tests 1217/1217. PR #108.

v5.3.0-alpha.49

Tier-3 PR-3a — Knowledge base API foundation. Schema + 11 routes + worker scaffold + 11 isolation tests. UI work (5 surfaces) intentionally split off into PR-3b/alpha.50 to keep this PR reviewable and ship the API layer + schema migration ahead of the heavier Tiptap-editor + structure-canvas redesigns. Foundation-first matches scope §5 option 2 (the alternative the scope doc recommended after weighing the single-PR option).

noteSchema (1 additive migration)

  • Collection.iconKey String? — material-symbol name for the structure-canvas tile rendering. NULL renders a fallback folder icon.
  • Collection.visibility String @default('public')'public' | 'restricted' | 'internal'. Indexed on (tenantId, visibility) for the public /knowledge filter.
  • Article.autosavedAt DateTime? — editor draft-autosave signal. Distinct from updatedAt (which Prisma auto-stamps on viewCount increments).
  • ArticleAiJob model — one row per AI job. Status transitions queued → running → succeeded | failed are written by the BullMQ worker; admins move succeeded rows to accepted or dismissed from the suggestions panel. 60-day retention; per-tenant + per-article indexes.
  • Migration: 20260513020000_knowledge_base_tier3.

note`@helpmesh/core` validators (5 new schemas)

  • ArticleAiJobKindSchema (6 kinds), ArticleAiJobStatusSchema (6 states), ArticleAiJobCreateSchema, ArticleAiJobListQuerySchema
  • KbBulkActionSchema with superRefine enforcing targetCollectionId for move and targetLocales for translate
  • KbCollectionReorderSchema

noteAPI routes (11 new + extended)

| Path | Method | Auth | Status | |---|---|---|---| | /api/v1/kb/articles | GET | session | extended (?segment, ?author, ?counts) | | /api/v1/kb/articles/bulk | POST | ADMIN | NEW (publish / unpublish / archive / move / translate; ownership-verified; translate fans out per-locale ArticleAiJob) | | /api/v1/kb/articles/:slug | PATCH | ADMIN | extended (?asDraft=true autosave path; sets autosavedAt; skips status flips + revision write) | | /api/v1/kb/articles/:slug/revisions | GET/POST | session/ADMIN | NEW (explicit "Save version" + revisions drawer) | | /api/v1/kb/articles/:slug/revisions/:version/restore | POST | ADMIN | NEW (writes a NEW revision, never rolls version back; audited) | | /api/v1/kb/collections | GET | session | extended (healthScore + articleCount + lastUpdatedAt + iconKey + visibility) | | /api/v1/kb/collections/reorder | PATCH | ADMIN | NEW (atomic $transaction; rejects stale id sets with HM-REORDER-STALE 409) | | /api/v1/kb/collections/:id/split-suggestion | POST | ADMIN | NEW (queues ArticleAiJob) | | /api/v1/kb/ai-jobs | GET | session | NEW (suggestions panel; cursor pagination) | | /api/v1/kb/ai-jobs/:id/accept | POST | ADMIN | NEW (per-kind apply: translate → new locale Article; summarize → seoMeta merge; rewrite-title → title update; auto-generate → body replace; sanitized HTML on every body write) | | /api/v1/kb/ai-jobs/:id/dismiss | POST | ADMIN | NEW (idempotent; terminal-state 409) | | /api/v1/kb/public/articles/:slug | GET | hostname-resolved tenant | NEW (publicHandler via withTenant; PUBLISHED + public-collection only; sanitized body; best-effort viewCount increment; top-3 related) | | /api/v1/kb/public/articles | GET | hostname-resolved tenant | NEW (?sort=trending\|recent&limit=6; limit hard-capped at 24; no body in projection) | | /api/v1/kb/public/collections | GET | hostname-resolved tenant | NEW (visibility=public + per-collection articleCount) |

noteWorker (`apps/worker`)

  • processArticleAiJob processor + article-ai-job queue registration. Status-machine + tenant-scoped run + CAS claim via updateMany. Per-kind LLM calls are scaffolded with pending: true placeholders so the suggestions panel can render a "model not yet configured" hint until the orchestrator wires land in a follow-up. The status-machine + audit + sanitization boilerplate is the production-critical part shipping today.

noteWeb helpers

  • apps/web/src/lib/kb/ai-job-queue.tsenqueueArticleAiJob(jobId, tenantId) helper. Used by /articles/bulk (translate fanout) and /collections/:id/split-suggestion. BullMQ jobId is derived from the row id so duplicate API calls don't create duplicate queue entries.

noteExplicitly deferred to PR-3b (alpha.50)

  • /settings/kb redesign — article list with filter pills + AI suggestions panel + bulk-action bar (variant 1)
  • /settings/kb/structure NEW — collection-hierarchy canvas with drag-to-reorder + health dots + "split this collection" CTA (variant 2)
  • /settings/kb/[articleId] redesign — Tiptap editor with autosave + revisions drawer + AI right-rail (variant 3, the largest piece — ~2-3 dev-days alone)
  • /knowledge/[slug] NEW — public article page with JSON-LD + related strip (variant 4)
  • /knowledge redesign — help-center home with semantic search + popular/recent strips (variant 5)
  • Real LLM orchestrator wiring for each ArticleAiJob kind — all 6 branches currently return { pending: true, reason } placeholders

noteQuality

  • pnpm typecheck — clean across all 16 packages
  • @helpmesh/web — 1188/1188 tests pass (was 1113 on alpha.48; +75 new across 11 KB isolation files)
  • 11 new isolation tests cover every route's tenant-scope predicate, role-gate, ownership-verification, and (for AI accept) HTML sanitization
  • Zero new any, function size ≤30 lines, structured error codes throughout

v5.3.0-alpha.48

Tier-3 PR-2 — Customers v2 redesign. Second tier-3 PR. Adds the GDPR deletion request flow on the customers list + detail surfaces, ships the single-admin-check signal, and extends the customers + threads APIs with the filters the new UI relies on. Most of the deletion-approval API surface already shipped in alpha.43-46; this PR layers the customer-facing UX on top of it.

noteSchema (1 additive migration)

  • DataDeletionRequest.customerIds: String[] — bulk-delete target array. Singular customerId stays populated with the first id for back-compat with the alpha.43 deletion-approvals UI and audit queries. Legacy rows are backfilled with a single-item array via UPDATE … SET customerIds = ARRAY[customerId].
  • Migration: 20260513010000_customers_tier3.

noteAPI extensions

  • GET /api/v1/customers — new ?include=deleted,inactive, ?counts=true, server-side ?tier= filter. Returns an aggregate: { active, inactive, deleted, byTier } block when counts=true so the filter pills can render counts without a second round-trip. Default predicate unchanged (still hides soft-deleted).
  • GET /api/v1/customers/:id — now projects pendingDeletion: { requestId, requestedBy, requestedAt, expiresAt } | null. Matches either the singular customerId column OR any bulk customerIds array that includes this id.
  • GET /api/v1/customers/:id/threads — accepts ?status=&priority=&channel=&from=&to= (multi-value via URLSearchParams.getAll). Every value is allowlist-validated against the Prisma enum before reaching the predicate.

noteREST API (new)

  • GET /api/v1/privacy/single-admin-check — session-min auth. Returns { adminCount, requireTwoAdminDelete, canDelete }. Drives the V8 single-admin block in /settings/data-privacy + the disabled state on the GDPR delete modal.

noteHelpers

  • paginated(data, nextCursor, hasMore, extras?) — optional extras block merged at the top level of the response envelope. Lets aggregate counts ride alongside paginated results without breaking the existing envelope.

noteUI

  • /customers — filter pills with live counts (?counts=true), Show inactive / Show deleted toggles, bulk-action bar (Export + Request GDPR delete; "Apply tag" intentionally disabled, scope §2.2), per-row pending-deletion + soft-deleted state with yellow / gray tinting, row-level Select checkboxes (disabled on pending or deleted rows).
  • /customers/:id — pending-deletion banner (V6) above the header with a Cancel-request button for the requester, "Request delete" action in the header (hidden when one is already pending).
  • /customers/:id/ticketsNEW sub-page. Full ticket history with multi-select status / priority / channel filter pills, server-side filtered via the alpha.48 /customers/:id/threads query params.
  • /settings/data-privacy — V8 single-admin block. Renders only when requireTwoAdminDelete=true AND adminCount < 2. Healthy workspaces don't see the warning.
  • GDPRDeleteRequestModal — shared component. Single or bulk target, confirmation phrase typing guardrail (DELETE CUSTOMER / DELETE N CUSTOMERS), single-admin warning, ≥10-char reason field, fans out to the existing POST /api/v1/privacy/delete per id.

noteAlready shipped pre-alpha.48 (scope §3 routes 4-8 + 9-extra)

  • POST /api/v1/privacy/delete (queue-not-execute under requireTwoAdminDelete) — alpha.43
  • GET/DELETE/POST /api/v1/privacy/deletion-requests (list, cancel, approve, reject) — alpha.43
  • canApproveDeletion helper (blocks self-approval + single-admin + expired + non-pending) — alpha.43, tested
  • GET /api/v1/privacy/deletion-requests/pending-count — alpha.46

noteExplicitly deferred

  • CustomerTag model + tag groups — own scope, not built (scope §2.2). "Apply tag" UI is disabled with tooltip.
  • CustomerNote model — own scope (scope §2.2). Detail-page notes preview already renders an empty state.
  • Company model + Customer.companyId FK + MRR/ARR — own scope doc later (scope §2.4).
  • Bulk-delete server endpoint — current flow fans out client-side; a one-shot bulk POST is a follow-up.
  • /api/v1/customers/export CSV — Export-selected button hooks to this URL with the selected ids, endpoint to follow.

noteQuality

  • pnpm typecheck — all packages clean
  • 4 new isolation tests (customers list, customers/:id, customers/:id/threads, privacy/single-admin-check)
  • Zero new any, function size ≤30 lines, RTL via logical properties preserved
  • Generic error responses with structured code fields

v5.3.0-alpha.47

Tier-3 PR-1 — Tenant dashboard (`/home`) redesign. First tier-3 PR. Reskins the agent home to the locked stat-card pattern, folds SLA compliance + CSAT into the KPI strip, adds a 24h/7d/30d volume toggle with hourly bar mode, and converts the recent-activity panel into a 5-column table with publicNumber-resolved ticket links. Zero schema delta.

noteAPI extensions (4 routes, no new endpoints)

  • GET /api/v1/analytics — new fields: slaCompliancePercent, slaCompliancePrevPercent, slaComplianceWindowDays, csatAverage, csatPrevAverage, csatResponseCount, avgFirstResponseMinutesPrev. All scoped through the existing baseWhere predicate; prev-period delta source uses a paired 7-day window (7–14 days ago). Returns null (not 100%) when the window is empty so brand-new tenants don't paint fake compliance numbers.
  • GET /api/v1/reports/volume — accepts ?bucket=hour|day (default day). Hourly mode runs DATE_TRUNC('hour', "createdAt") over the last 24h, hard-capped at 24 buckets. Day mode is unchanged; the hourly query is skipped via Promise.resolve([]) when bucket=day so there's no wasted DB round-trip.
  • GET /api/v1/system/activity — rows whose entity === 'Thread' now carry a thread: { id, publicNumber, subject, status } projection so the dashboard table can render PRO-6-000131 instead of the bare cuid. Single tenant-scoped IN-list query post-aggregation; non-Thread rows get thread: null.
  • GET /api/v1/analytics/attention — every list row now carries slaCountdownMs (ms until soonest open SLA deadline, negative when breached, null when no deadline applies). Server-computed so the UI can render "expires in 12m" without reformatting client-side.

noteHelpers (`apps/web/src/lib/home-helpers.ts`)

  • slaSparkline(series) — 100% on zero-volume days (no false dip)
  • responseTimeSparkline(series) — null minutes → 0 (no-data gap)
  • deltaForMetric(current, previous, { lowerIsBetter? }) — unified delta tone resolver; returns null when previous is zero/null (no fake "100% increase" noise)

noteUI (`/home`)

  • KPI strip refresh — Team + Agent modes now render Open / Avg response / CSAT / SLA compliance tiles with prev-period deltas + iconTone mapped to scope §4.2. Viewer mode unchanged.
  • Volume card — 24h/7d/30d segmented toggle. 24h renders a bar chart of hourly buckets via bucket=hour; 7d/30d uses the existing area chart with fillVolumeGaps. Single state, fetched lazily on window change.
  • Recent activity — list-with-avatars → 5-column table (Action / Ticket / Agent / Time / Status). Ticket cells render #publicNumber as a link to /inbox/<id>; non-thread rows fall back to a font-mono Entity#hash label. Reuses the same export name and import path so the page consumer doesn't change.
  • Header — action button pair now reads Export report + New ticket (primary). Matches the preview's tenant-dashboard.html L154-173.

notePreview banned-pattern check

  • No ⌘K chip or <CommandShortcut> rendered. The existing chrome search button already opens the palette via onClick; placeholder text is plain ("Search tickets, customers, articles, settings…"). Per feedback_no_shortcuts.md.

noteOut of scope (deferred to follow-ups)

  • AIHandoffPanel / TodaysMeetingsPanel / OnboardingChecklist — explicitly NOT touched (scope §1 non-goal).
  • /api/v1/dashboard/overview aggregator — considered + rejected in scope §2.1 (per-endpoint cache-control + single-slow-query risk).

noteQuality

  • 4 new isolation tests for the API extensions (analytics, attention, volume, system/activity)
  • 12 new unit tests on the three new helpers
  • Zero schema migration, zero new any, function size ≤30 lines, RTL via logical properties

v5.3.0-alpha.46

Tier-2 closeout — 7 carry-overs landed in one bundled PR. No tier-2 PR scope was left unfinished, but a handful of polish + compliance hygiene items were sitting on a follow-up list. They all ship together here so the tier-3 work that starts next isn't competing for prod attention with cleanup commits.

noteSchema (1 additive migration)

  • MessageSystemEvent enum + nullable Message.systemEvent column. Drives the new customer-safe SYSTEM-event projection on the widget. Existing rows untouched (NULL = "agent-only"), so this is safe on a live Message table without backfill.
  • Migration: 20260512230000_tier_2_closeout.

note`@helpmesh/core`

  • message/systemEventPORTAL_VISIBLE_SYSTEM_EVENTS (4-entry allowlist) + isPortalVisibleSystemEvent() helper. Unit-tested.

noteWorker

  • deletion-request-expiry processor + hourly cron (deletion-request-expiry-cron). Flips stale AWAITING_APPROVAL DataDeletionRequest rows to EXPIRED once expiresAt has passed, plus an AuditLog row in the same transaction. Closes the compliance hygiene gap noted in alpha.43's requireTwoAdminDelete work (#1).

noteWidget API

  • GET /api/widget/v1/articles/:slug/print — print-friendly HTML page (browser "Save as PDF" path). Session-cookie auth, tenant + PUBLISHED filter, sanitized body, robots-noindex header. No worker / no chromium / no S3 — chosen over server-rendered PDFs because article export volume is low and the report-pdf-worker pattern is heavier than the use case warrants (#8).
  • GET /api/widget/v1/threads/:id/messages — extended with customer-safe SYSTEM-event projection. SYSTEM rows are now only surfaced to the portal when their systemEvent is in the allowlist; legacy NULL-tagged SYSTEM rows stay invisible (#6).

noteREST API

  • GET /api/v1/privacy/deletion-requests/pending-count — ADMIN-only, returns { pending, overdue } for the sidebar badge. Cheap two-COUNT query, tenant-scoped (#2).
  • GET /api/v1/billing/referrals — OWNER-only, aggregates real referral counts (Tenant.referredByCode = caller.referralCode) + summed Credit.amountCents where source = REFERRAL. Drops the zero-state stub that was carrying the prior placeholder copy (#7).

noteUI

  • apps/web/src/app/(agent)/settings/layout.tsx — pending-count badge on "Deletion approvals" nav entry. Tone flips blue → red when any approval window has elapsed (the worker will sweep it on its next hourly tick). Polls every 60s.
  • apps/web/src/components/settings/api-keys/EditKeyDialog.tsx — "Add custom cap" surface inside the existing edit modal. Posts to the alpha.42 /api/v1/api-keys/:id/rate-limits endpoint that previously had no UI (#3).
  • apps/web/src/app/(customer)/portal/tickets/[id]/page.tsx — Pusher client subscribes to private-portal-thread-{id} via the alpha.45 widget auth route. New messages render in real time; thread updates re-trigger the existing refetch. Graceful no-op when NEXT_PUBLIC_PUSHER_KEY is unset (#4).
  • apps/web/src/app/(customer)/portal/help/[slug]/page.tsx — "Save as PDF" button in the article header, opens the new /print endpoint in a new tab (#8).
  • apps/web/src/components/settings/billing/ReferralHero.tsxhasStatsEndpoint flips to true now that the backing endpoint exists.

noteHooks

  • usePortalRealtime — small wrapper around pusher-js for the customer portal. Stable-ref callback pattern so the host page can re-render without resubscribing.

noteExplicitly deferred

  • #5 — server-side V5 view-as proxy. The current TEAM_LEAD+ read-only shell stays. A true hm_session-bridge proxy with cross-tenant safety + CSP carve-out is 1.5-2 dev-days and ships with the portal-hardening PR. apps/web/src/app/(agent)/customers/[id]/view-as/page.tsx carries a TODO(portal-hardening) comment with the rationale.
  • Template gallery side-sheet on `/settings/automations`. Belongs with the tier-3 integrations PR — the page's handleBrowseTemplates toast was updated to point reviewers there.

noteQuality

  • 4 isolation tests added (pending-count, billing referrals, article print, systemEvent helper)
  • Zero new any, function size ≤30 lines, RTL via logical properties preserved, error responses generic with code fields

v5.3.0-alpha.45

Customer portal — closes the second tier-2 PR. 5 preview-locked surfaces shipped, P0 Pusher auth gate closed, ArticleVote + CSAT-one-per-thread schema landed. Tier-2 is DONE with this PR; tier-3 queue (7 scope docs, ~110 dev-days) is next.

noteDecisions locked at kickoff

  • **Reuse /api/widget/v1/*** — explicitly NOT a parallel /api/portal/* namespace. The portal cookie shape, JWT-or-anon auth, tenant isolation, and Pusher channels all already match.
  • All 3 day-1 risks from scope §6 verified clear before code: tenant resolution at custom domain (already handled by server/middleware/tenant.ts), MessageType.NOTE (already in enum), Pusher auth gating (hole confirmed on existing /api/pusher/auth, closed by new widget-side route).

noteSchema (1 migration, additive)

  • ArticleVote table — idempotent helpful voting. voterHash = SHA-256(tenantId + customerId-or-sessionId). Unique on (articleId, voterHash) so a customer cannot inflate counts. Counter increments stay on Article for fast list rendering.
  • CSATResponse.@@unique([threadId, customerId]) — Risk #6. Existing duplicate rows pruned by migration before the index lands.
  • Migration: 20260512200000_customer_portal_pr.

note`@helpmesh/core` (validation + helpers)

  • schemas/widgetThreadFilters?status=&q=&limit= shape, PortalThreadStatusSchema excludes SPAM (never customer-visible).
  • schemas/widgetCsat — rating 1-5 + optional comment ≤2000.
  • schemas/widgetArticleFeedback — just helpful: bool. Identity derived server-side.
  • schemas/impersonateCustomer — customerId + reason. IMPERSONATION_SESSION_TTL_MIN = 30.
  • crypto/voterHash.articleVoterHash — SHA-256 with per-tenant salt. Server-only.

noteWidget API surface (3 extended + 8 new, all session-cookie auth + tenant-scoped)

  • Extended GET /api/widget/v1/threads — filters + csatPending flag for the "Rate this" chip.
  • Extended POST /api/widget/v1/threadscategory (→ customFieldValues.category), priority, attachments[] (10×10MB), consentAccepted (upserts a Consent row).
  • New GET /api/widget/v1/threads/:id — ownership-gated detail with safe assignee profile (id+name+avatarUrl, NEVER email).
  • New POST /api/widget/v1/threads/:id/reopen — 14-day window from resolvedAt (Risk #9); CLOSED is final.
  • New POST /api/widget/v1/threads/:id/csat — translates Prisma P2002 to HM-CSAT-ALREADY-SUBMITTED (409).
  • New GET /api/widget/v1/articles/:slug — PUBLISHED only, sanitized body, computed readTimeMinutes (~240wpm) + helpfulPercent + related-articles (top 3 same Collection by viewCount).
  • New POST /api/widget/v1/articles/:slug/feedback — idempotent voting. New vote increments; same-flag re-vote no-ops; flip decrements old + increments new. Article counters stay authoritative.
  • New POST /api/widget/v1/uploads — presigned S3 PUT with key tenants/{id}/portal/{owner}/attachments/{uuid}/{name}. Stricter content-type allowlist than the agent surface (no zip / exe).
  • New POST /api/widget/v1/pusher/authP0 ownership gate. Whitelists only private-portal-thread-{id}. Identified-customer required. 404 (not 403) on foreign thread id. The existing agent-side /api/pusher/auth only checks "is logged in"; this is the customer-side equivalent done right.
  • New POST /api/v1/impersonate/customer — TEAM_LEAD+, 30-min TTL, self-impersonation blocked (HM-IMPERSONATE-SELF), AuditLog row in same transaction.
  • New DELETE /api/v1/impersonate/customer/:sessionId — only minter can end (HM-IMPERSONATE-NOT-OWNER).

noteUI (5 surfaces + chrome polish)

  • Chrome polish (portal/layout.tsx): brand-color CSS variable at the layout root so every descendant inherits tenant brand via --hm-blue-500 override. RTL via document.documentElement.dir based on branding.defaultLocale (ar/he/fa/ur). Wider main on /portal/help/* per scope §4.4. Help-center nav entry added.
  • V1 `/portal` REWRITE — server-side filters + 250ms debounced search, per-card priority pill (URGENT only), CSAT-pending chip on RESOLVED, empty-state branches with "Help yourself faster" 3-tile row pulled from GET /api/widget/v1/kb?limit=3.
  • V2 `/portal/new` REWRITE — inline KB suggestions (600ms debounce, dismissible), real uploads via the new /uploads endpoint (presign + PUT, serial for UX feedback), consent checkbox (upserts a Consent row), priority enum aligned to backend UPPER_CASE, inboxId resolved from /sessions (fixes the pre-existing inboxId: '' bug).
  • V3 `/portal/tickets/[id]` REWRITE — customer-right / agent-left / centered-system bubble layout, dedicated AI bubble (purple Sparkles), CSAT panel replaces composer on RESOLVED + un-rated, reopen button on RESOLVED + rated, deep-link #rate scrolls panel into view.
  • V4 `/portal/help/[slug]` NEW — full article page (breadcrumb + meta row + sanitized prose body + voter card + ticket-CTA + sticky related-articles sidebar). Plus /portal/help minimal index with debounced search (previously a 404).
  • V5 `/customers/[id]/view-as` NEW — TEAM_LEAD+ impersonation preview. Sticky orange banner with expiry countdown + Exit; "what's hidden" notice; read-only ticket list with per-card links to the agent inbox. Lives under (agent) route group (NOT /admin/* which is SUPER_ADMIN-only).

noteStability

  • +47 new tests (web 1033/1033 green; 988 → 1033). Core 457/457 unchanged. db typecheck clean. Lint clean.
  • No new external deps. No env var changes.

noteBehavior notes

  • The pre-existing inboxId: '' bug on the old /portal/new would have 400'd every customer-portal ticket submission. V2 fixes by fetching from /sessions on submit. Existing direct widget integrations are unaffected (they pass inboxId explicitly).
  • IP allowlist + rate-limit middleware do not gate the portal (session-cookie auth path, not API-key). No change here.
  • /portal/help/* lives under the X-Robots-Tag-blocked /portal namespace; AI crawlers are blocked. The marketing AEO surface remains /knowledge/{slug}.

noteTier-2 status

16/16 settings-core variants + 5/5 customer-portal variants shipped. Next: tier-3 queue (tenant-dashboard recommended first, smallest ~4d, 0 schema migrations). Customer-portal closeout.

v5.3.0-alpha.44

Settings · Developer UI — completes the surface set started in alpha.43. All 5 preview-locked surfaces shipped, sidebar updated, legacy /settings/developer retired. Tier-2 closeout: settings-developer is DONE; only customer-portal remains before tier-3.

noteUI (5 surfaces)

  • V1 `/settings/api-keys` REWRITE. Honesty banner, 4 StatCards (Active / Expiring ≤30d / Requests-24h honest empty / Revoked all-time), filter row (search / status / scopeGroup), KeysTable with computed badges (Quiet / Inactive / Expired / Revoked / Rotating), per-row Popover menu (Edit / Rotate / Set expiry / Revoke). EmptyState fallback.
  • V2 `/settings/api-keys/new` NEW dedicated route. 3-step Stepper:
  • V3 `/settings/rate-limits` REWRITE. Plan-tier banner, 4 StatCards (Requests 24h / 429s % of total / Active windows / Top offender API key), pure-CSS bar chart (no chart library), plan-defaults matrix from PLAN_RATE_LIMIT_DEFAULTS. Auto-refresh every 60s matching the rollup worker cadence.
  • V4 `/settings/ip-allowlist` NEW. Honesty banner (enforcement-on vs off), inline AddIpEntryForm with soft-warn at 40/50 cap, IpAllowlistTable (tenant-wide vs key-specific badge, last-seen, remove), IpDenialsTable (90-day log with Geo + Tor flag + one-click + Allowlist CTA that posts /32 or /128).
  • V5 `/settings/deletion-approvals` NEW. Tab strip (Pending / Approved / Rejected / Expired) with groupBy counts. DeletionRequestCard with scope summary 4-stat grid, expiry countdown, action column branching on self-requester (Cancel) vs other admin (Approve / Reject modal with reason). Single-admin tenant red banner.

1. Name (env auto-detected from NODE_ENV — Step 1 collapsed per scope §4.2 Q1) 2. Scopes + expiry: 3 quick presets (Read-only / Full integration / Webhook-only) + collapsible scope catalog (8 groups) + 4 expiry tiles + red admin-superscope warning 3. Reveal secret with auto-copy on mount, no re-show, no sessionStorage, beforeunload guard, zero-out on unmount, quickstart tabs (cURL / TypeScript SDK / Python / Node) via @helpmesh/core quickstartSnippet().

noteSidebar + redirects

  • apps/web/src/app/(agent)/settings/layout.tsx: Developer section grows from 3 → 5 entries (adds IP allowlist + Deletion approvals).
  • apps/web/src/app/(agent)/settings/developer/page.tsx: replaces the legacy tab page with a page-level redirect() (per feedback_next_config_redirects_shadow.md, never next.config.mjs). ?tab=webhooks|integrations|rate-limits preserved.

noteNew shared components (15 files)

  • components/settings/api-keys/: KeysTable, EditKeyDialog, RotateKeyDialog (auto-copy reveal), RevokeKeyDialog (type-name confirm), CreateKeyStep1Name, CreateKeyStep2Scopes, CreateKeyStep3Reveal.
  • components/settings/rate-limits/: RateLimitsChart, PlanDefaultsTable.
  • components/settings/ip-allowlist/: AddIpEntryForm, IpAllowlistTable, IpDenialsTable.
  • components/settings/deletion-approvals/: DeletionRequestCard.

noteStability

  • @helpmesh/web typechecks clean across 96 test files / 983 tests passing.
  • Lint clean apart from 2 pre-existing console warnings in lib/api-handler.ts + lib/logger.ts.
  • No new packages.

noteWhat's still pending

  • Pending-count badge on the Deletion approvals sidebar entry (needs a tenant-wide signal endpoint not currently exposed).
  • Daily worker for AWAITING_APPROVAL → EXPIRED transitions (cheap ~20 LoC follow-up; the gate already rejects approvals where expiresAt <= now via canApproveDeletion).
  • After this lands: customer-portal PR (locked scope, 5 surfaces).

v5.3.0-alpha.43

Settings · Developer — BACKEND FOUNDATION (no UI yet). Ships the schema migration, all API routes, the IP-allowlist middleware, and the rate-limit usage rollup worker for the 5 preview-locked developer surfaces. Zero behavior change for live tenants because both new flows are opt-in (no UI exposes the opt-in yet). UI ships in a follow-up PR.

noteWhy ship backend-only

  • Tenant.requireTwoAdminDelete defaults false for legacy tenants → /api/v1/privacy/delete keeps its synchronous behavior. No existing API consumer is broken.
  • IP allowlist has zero entries on every tenant → middleware no-ops for everyone until someone adds a CIDR (no UI to add one yet).
  • Approve/reject/cancel endpoints exist but are unused until the UI lands.

noteAdded — Schema (1 migration, additive only)

  • DataDeletionRequest: 7 new nullable fields for the two-admin approval gate (approvedById/At, rejectedById/At, rejectReason, scopeSummary JSON, expiresAt). Status vocabulary widened: AWAITING_APPROVAL | APPROVED | REJECTED | EXPIRED. Legacy PENDING + completedAt rows promoted to COMPLETED by the migration.
  • ApiKey.deprecatedAt (nullable) — set by the rotate endpoint for a 24h grace window; resolveTenant treats the key as revoked past this timestamp.
  • Tenant.requireTwoAdminDelete (bool, default false) — opt-in flag for the two-admin gate. Defaults false for legacy tenants, will be set true for new tenants when the create-tenant flow flips it (follow-up).
  • RateLimitUsage15Min (new table) — 15-min rollup of API request counts. Worker populates from Redis counters; 30-day TTL via the audit-log-retention sweep.
  • IpAllowlistDenial (new table) — append-only log of API-key requests rejected by the IP-allowlist middleware. 90-day TTL.

Migration: 20260512100000_settings_developer_pr. Zero application-side backfill.

noteAdded — `@helpmesh/core` (validation + helpers)

  • schemas/ipAllowlistEntry — CIDR v4+v6 via ipaddr.js (new MIT dep, 15kb gzip) + tenant caps (50 / 20).
  • schemas/apiKeyRateLimit — scope-group enum (read|write|ai|webhook|bulk|all), PLAN_RATE_LIMIT_DEFAULTS table, scopeGroupForPath() router.
  • schemas/deletionApproval — status vocabulary, list filter, reject reason, scope-summary JSON shape, 7-day expiry constant.
  • privacy/canApproveDeletion — single-source approval gate. Codes: HM-DELETION-NOT-PENDING | HM-DELETION-EXPIRED | HM-DELETION-SELF-APPROVAL | HM-DELETION-SINGLE-ADMIN.
  • api-keys/policycomputeKeyBadges() with thresholds (60d inactive-warn, 90d stale, 30d expiry-soon, 24h/72h grace).
  • api-keys/quickstart — cURL/TypeScript/Python/Node snippet templates for the create-key reveal screen.
  • security/cidripInAnyRange() (v4/v6, no 4-in-6 coercion) + clientIpFromHeaders().

noteAdded — APIs (12 new + 2 modified, all ADMIN-gated, OpenAPI-registered)

  • GET /api/v1/api-keys (extended): ?status=active|expiring|expired|inactive|revoked, ?scopeGroup=, ?q=. Response now carries computed badges + a stats summary (activeCount, expiringSoonCount, revokedAllTimeCount; requests24h + throttled24h are null until the rollup table has data — honest empty-state).
  • PATCH /api/v1/api-keys/:id — edit name/scopes/expiresAt. Never returns raw key material.
  • POST /api/v1/api-keys/:id/rotate — issues a new secret, deprecates the old for graceHours (default 24h, max 72h). 409 if already mid-rotation.
  • GET /api/v1/rate-limits — plan defaults + 24h usage buckets + top-offender API key.
  • GET/POST /api/v1/api-keys/:id/rate-limits and PATCH/DELETE .../[rid] — per-key overrides. 409 on duplicate scopeGroup.
  • GET/POST /api/v1/ip-allowlist + DELETE /:id — CRUD with cap enforcement (HM-IP-ALLOWLIST-CAP).
  • GET /api/v1/ip-allowlist/denials — recent middleware-rejection log.
  • GET /api/v1/privacy/deletion-requests (extended): ?status= filter + per-status counts via groupBy.
  • POST /api/v1/privacy/deletion-requests/:id/approve | reject | cancel — three terminal transitions backed by canApproveDeletion.
  • POST /api/v1/privacy/delete (extended): when tenant.requireTwoAdminDelete is true, returns 202 + AWAITING_APPROVAL request row. Otherwise unchanged.

noteAdded — Middleware (IP allowlist enforcement)

  • server/middleware/ip-allowlist: cached snapshot of CIDRs by scope (tenant-wide vs per-key), 30s TTL, evicted on write.
  • lib/api-handler: calls checkIpAllowlist + logDenial only on the API-key auth branch (apiKeyScopes !== null). Browser sessions, super-admin impersonation, and subdomain-only flows skip the check. Session-bypass failsafe locked by a source-contract test so a future refactor can't move the check.
  • 403 code: HM-AUTH-IP_NOT_ALLOWED. Per the kickoff Q: "admin can always log into helpmesh.io/inbox from any IP and fix a bad allowlist".

noteAdded — Worker (`apps/worker`)

  • rate-limit-rollup processor: every 60s SCANs Redis rl_count:* counters, GETDELs them, aggregates by tenant + apiKey + scopeGroup + 15-min bucket, and upserts into RateLimitUsage15Min. Lost-data-point is the safer failure mode than double-counting.
  • lib/api-handler + server/middleware/rate-limit.bumpUsageCounter: fire-and-forget INCR on every API request — separate suffix for throttled (429) vs OK paths.

noteTests

  • +123 new tests across @helpmesh/core (34: CIDR + deletion approval gate) and @helpmesh/web (89: source-contract isolation tests + 8 unit tests on checkIpAllowlist + 3 session-bypass guards + behavior contract on /privacy/delete).
  • All packages green: web 983/983, core 457/457, db 222/222, worker 66/66, auth 4/4.
  • Monorepo typecheck clean. Lint clean.

noteBehavior note (CHANGELOG-load-bearing)

  • /api/v1/privacy/delete semantics change is GATED by tenant.requireTwoAdminDelete defaulting false. New tenants will be created with it true once the tenant-create flow flips it; existing tenants keep the synchronous behavior until they opt in via the UI (not in this release).
  • IP allowlist enforcement is OFF for every tenant in this release because no tenant has entries. Adding any entry via the new POST /api/v1/ip-allowlist will start enforcing for that tenant's API-key callers — session auth always bypasses.

noteStill to ship (next PRs)

  • 5 UI surfaces: API keys list redesign + Create-key 3-step flow + Rate limits page + IP allowlist page + Deletion approvals queue.
  • Settings sidebar: split Developer section into 5 entries + pending-count badge.
  • Legacy /settings/developer page → 301 redirect to /settings/api-keys (page-level, not next.config).
  • Worker for EXPIRED flips on DataDeletionRequest past expiresAt.
  • ECS one-shot migrate job before the code tag deploys (per feedback_deploy_does_not_run_migrations.md).

Per docs/plans/pr-settings-developer-scope.md. PR #101 on feat/settings-developer-pr.

v5.3.0-alpha.42

Settings-core closeout — Branding + Data privacy + Billing (PR-E). Ships the final 3 settings-core variants. 14/14 settings-core variants now shipped. Strictly-serial cadence locked: settings-developer and customer-portal kick off after this lands.

noteLocked decisions (user-confirmed before kickoff)

  • Custom CSS textarea SHIPS in PR-E behind Growth+/Scale/Enterprise plan gate, with cssom-style sanitization + type-to-confirm modal + AuditLog on every write
  • "Live preview" rail SHIPS as a real iframe (postMessage handshake) — the marketing promise
  • Sub-processor 30-day pre-notice email worker SHIPS in PR-E (daily 02:00 UTC scheduler)
  • ZERO changes under apps/web/src/app/api/billing/ (Stripe contract untouched)
  • AI provider per-row attribution DEFERRED (clean implementation needs a different data model; billing UI shows aggregate "Total" only, honestly subtitled "Provider breakdown coming soon")

noteAdded — Schema (4 additive Tenant columns, 1 migration)

  • Tenant.brandingTokens (jsonb default '{}') — typography + dark-mode tokens, validated by BrandingTokensSchema
  • Tenant.customCss (text nullable) — tenant-supplied CSS, validated by CustomCssSchema (max 8KB, dangerous-token rejection: @import, external url(), expression(, behavior:, javascript:, vbscript:, <script, position: fixed/sticky). Render-time scoping to [data-tenant="<id>"] is a help-center concern (deferred).
  • Tenant.retentionPolicy (jsonb default '{}', DB-side backfilled from auditRetentionDays) — per-entity retention { closedTickets, attachments, auditLog, csat: { days, postExpiry: 'delete' | 'anonymize' | 'cold_storage' } }
  • Tenant.subProcessorAck (jsonb nullable) — { acknowledgedAt, version, byUserId }

Migration: 20260512090000_pr_e_foundation. Zero application-side backfill needed.

noteAdded — Zod schemas (4 new) + static data (3 new) in `@helpmesh/core`

  • BrandingTokensSchema — bodyFont/displayFont/allowDarkMode
  • CustomCssSchema + isCustomCssValid() helper
  • RetentionPolicySchema + DEFAULT_RETENTION_POLICY constant
  • SubProcessorAckSchema + SubProcessorAckRequestSchema
  • data/subProcessors.tsSUB_PROCESSORS list + SUB_PROCESSOR_VERSION constant + isAckCurrent() helper. 6 entries: AWS, Anthropic, OpenAI, Postmark, Stripe, Pusher.
  • data/complianceStatus.tsCOMPLIANCE_STATUS array. Honest wording per CLAUDE.md: ISO 27001 "in progress · Type I targeted Q3 2026", SOC 2 "in progress · readiness ~64/100", GDPR/PDPL/DPDPA "in effect".
  • fixtures/sampleInvoice.tsSAMPLE_INVOICE + sampleInvoiceTotalCents() for the branded-invoice-preview empty state

noteAdded — APIs (5 new + 1 extended)

  • PATCH /api/v1/tenant (EXTENDED, ADMIN) — accepts brandingTokens (full-shape JSON, validated) + customCss (Zod-validated, plan-gated 403 HM-PLAN-INSUFFICIENT when plan ∈ {TRIAL, STARTER}). Allowed: GROWTH | SCALE | ENTERPRISE. AuditLog auto-written by apiHandler.
  • GET/PATCH /api/v1/settings/retention (NEW) — PATCH is OWNER-tier (audit-retention impact). Enforces monotonic-up for closedTickets/attachments/csat (can only lengthen) and free range [30, 3650] for auditLog. Returns DEFAULT_RETENTION_POLICY for legacy tenants with {}.
  • GET /api/v1/settings/sub-processors (NEW, session) — returns { processors, version, ack, isCurrent }
  • POST /api/v1/settings/sub-processors/ack (NEW, ADMIN) — version match required (400 HM-VERSION-MISMATCH), stamps {acknowledgedAt, version, byUserId}
  • GET /api/v1/settings/compliance-status (NEW, session) — aggregator with live counts: open DataExportRequest (PENDING/PROCESSING), open DataDeletionRequest (PENDING/PROCESSING), open BreachIncident (OPEN/CONTAINED), DpoContact presence boolean
  • GET /api/v1/billing/ai-usage (NEW, session) — current period AI usage; honestly returns providers: [{ name: 'total', ... }] since per-provider attribution is deferred. Cost rates from @helpmesh/core/billing/ai-cost (estimateAiCostUsd, blended Sonnet+Haiku rates).
  • GET /api/v1/usage (EXTENDED) — additive optional aiByProvider field

noteUI (3 surfaces built in parallel by 3 sub-agents)

  • `/settings/branding` REWRITE (~765 lines page + 12 new component files ~1400 lines):
  • `/settings/data-privacy` NEW (~161 lines page + 5 components ~1000 lines):
  • `/settings/billing` REWRITE (951→506 lines page + 12 sub-components ~1370 lines):

- Logo & favicon (lifted into LogoUploadZone component — preserved S3 presigned 3-step dance verbatim) - Color palette + dark-mode toggle (writes brandingTokens.allowDarkMode) - Typography (writes brandingTokens.bodyFont + displayFont) - White-label section with custom-domain status row + reply signature + Powered-by toggle (plan-gated) - Live preview rail — real iframe at /preview/branding?surface=help|widget|email, postMessage handshake with strict origin check, hot-updates on form change. 3 client components render the surfaces. - Custom CSS section — advanced section, plan-gated, type-to-CONFIRM modal on first save - 3 compliance tiles from compliance-status endpoint - Retention table with client-side monotonic-up enforcement (number min={currentDays} for non-audit entities) - 2 customer-data-request cards (Export + Delete with type-to-DELETE modal) wiring to existing GDPR endpoints - Sub-processors table with conditional re-ack banner (only when isCurrent === false) and "Up to date" pill when current - 11 sections matching variant 18 preview - Zero Stripe contract changeschange-plan body shape preserved (buildChangePlanBody() is the sole producer, source-string regex test guards drift) - AI usage section honestly shows the single "Total" bar with "Provider breakdown coming soon" subtitle - Referral counts initialized to zero with hasStatsEndpoint={false} and TODO comment — no fake numbers - Branded invoice preview uses OWNER membership name (not viewer's) - Payment-method "Add new" opens Stripe portal in same tab (never embed Stripe Element)

noteAdded — Worker (sub-processor pre-notice job)

  • processSubProcessorNotice in apps/worker/src/processors/sub-processor-notice.ts (~140 lines)
  • Daily scheduler on the low-priority queue (pattern: '0 2 * * *' — 02:00 UTC)
  • For every ACTIVE tenant whose subProcessorAck.version is older than SUB_PROCESSOR_VERSION: enqueues an email-send critical job + writes an InAppNotification row, both idempotent
  • Notifies OWNER + ADMIN memberships only
  • Idempotency: noticeToken subproc-notice:{tenantId}:{version} used as BullMQ jobId AND as the InAppNotification title suffix for findFirst-dedupe

noteTests

  • +179 web tests (715 → 894)
  • +11 worker tests (55 → 66)
  • Repo total: 1419 → 1609 (net +190 across PR-E)
  • All 24 core test files (423), 5 db test files (222), 1 auth test file (4), 81 web test files (894), 6 worker test files (66) green

noteQuality gates

  • pnpm -r typecheck clean across all 12 packages
  • pnpm -r lint clean (only 2 pre-existing console warnings in api-handler.ts + logger.ts)
  • pnpm --filter @helpmesh/web build clean Next build
  • Stripe-untouched sanity: git diff main..HEAD -- apps/web/src/app/api/billing/ is EMPTY

noteOut of scope (deferred follow-ups)

  • AI provider per-row attribution (needs a different data model — child table or join key change that doesn't break the existing tenantId_period upsert). Billing UI honestly shows aggregate-only until this lands.
  • CSS render-time injection on /help/* pages (column is in place; the help-center page consuming it is a separate concern)
  • Stripe Element embed (PCI scope — portal-only stays)
  • Breach-notification admin UI (BreachIncident model exists; admin filing surface is a separate PR)
  • /api/v1/billing/referrals aggregator endpoint (page is wired with zeros and a TODO)
  • Multi-year sub-processor list expansion + per-jurisdiction sub-processor variants
  • Powered-by toggle gating UX polish (currently a tooltip + upgrade link)

v5.3.0-alpha.41

Tier-2 settings wave 3 — AI page redesign + automation flow editor (PR-D-flow). Closes the last 2 settings-core preview surfaces (variants 10, 12) and the AI route restructure that was reverted from alpha.38. Scope locked with user: ONE PR, vertical-only canvas (no graph lib), dry-run-only test-fire.

noteAdded — Schema (1 new table, 2 indexes)

  • AutomationRun (NEW) — execution history rows. Columns: id, createdAt, tenantId, automationId, threadId?, status (matched|skipped|error), errorMessage?, durationMs, input(jsonb). Cascade-delete from both Tenant and Automation. 30-day retention is a worker concern (not part of this migration).
  • Composite index (tenantId, automationId, createdAt DESC) — tuned for the flow-editor recent-runs rail.
  • Composite index (automationId, status) — tuned for status filtering on the runs page.

Migration: 20260511020000_automation_run.

noteAdded — Zod schemas in `@helpmesh/core`

  • AutomationRunStatusSchema'matched' | 'skipped' | 'error'.
  • AutomationRunInputSchema — sanitized trigger snapshot { event: string; payload?: Record<string, unknown> }.
  • TestFireRequestSchema — body shape for the dry-run endpoint.

noteAdded — APIs (3 new + 1 restructured)

  • GET /api/v1/automations/:id/runs (NEW, session-tier) — cursor pagination, limit≤100, returns sanitized run rows (never the raw input payload per .claude/rules/data-handling.md). 404 on cross-tenant id (never 403).
  • POST /api/v1/automations/:id/test-fire (NEW, ADMIN) — dry-run only. Evaluates ConditionGroupSchema against the supplied event payload, writes an AutomationRun row, returns matched-action list. Never executes side effects (no webhook calls, no label writes, no assignments, no notifications). 12 operators supported: equals, not_equals, contains, not_contains, starts_with, ends_with, gt, gte, lt, lte, is_set, is_not_set. PII fields in payload are stripped at the API layer before persistence (15-key denylist: email, phone, body, bodyHtml, bodyText, customer, customerEmail, customerPhone, from, to, cc, bcc, apiKey, token, password).
  • GET/PATCH /api/v1/ai (RESTRUCTURED) — PATCH tier tightened from AGENT to ADMIN. Accepts BOTH legacy flat shape AND new nested { ai: AiSettingsInput } shape. GET returns both data.settings (flat, back-compat) and data.ai (nested) so the transitional deploy never breaks a client mid-flight. API key values never returned over the wire — only hasKey: boolean per provider. 5 flat keys still mirrored on nested writes because the worker + cost-tracker read them directly: aiEnabled, aiMonthlyBudgetUsd, aiDailyTokenLimit, aiProvider, aiModel.

noteUI built in parallel by 2 sub-agents

  • `/settings/ai` (REWRITTEN, ~900 lines, variant 10) — 2 stat tiles (Spent + Tokens; cache-hit tile hidden because the orchestrator doesn't track that metric — no fake stats), 3 provider cards (Anthropic / OpenAI / Gemini) with hasKey status + model select + disconnect, monthly budget cap + alert threshold, auto-draft (tone segmented + length slider + system prompt textarea), 5 per-feature toggles, 3 guardrail toggles. PATCH body uses nested shape.
  • `/settings/automations/[id]` (NEW, ~1100 lines, variant 12) — vertical canvas (trigger card → condition card → action chain), no graph library, no drag-to-connect, no multi-branch paths. Right rail (320px) shows selected-node config form + recent runs list. Sticky header with back link + name input + Live pill + Test fire button + Save. Test-fire modal validates synthetic JSON payload client-side, hits /test-fire, shows matched-action list. Disabled when id === 'new' (tooltip "Save the rule first").
  • Treats id === 'new' as create mode — POST on first save, router.replace to the persisted URL.

noteList page wiring

  • /settings/automations "New rule" button now navigates to /settings/automations/new (was a TODO toast).
  • Row chevrons already navigated to /settings/automations/{id} (shipped alpha.40); the destination page now exists.
  • "Browse templates" CTA still pending — pushed to PR-E.

noteTests

  • 102 new tests across the 3 new/restructured routes (19 + 26 + 23 contract tests + 6 + 7 isolation tests + 23 ai route tests, includes pre-existing transform tests still green). Web test count: 634 → 715. Repo total: 1279 → 1419 (net +140 across PR-C tests, PR-D tests, PR-D-flow).
  • Tenant isolation tests follow the established source-string + Zod contract pattern (no live HTTP+DB harness exists in this repo today; a follow-up could build one).

noteBackwards compatibility

  • AI route accepts both shapes; GET returns both. The legacy /settings/ai page would still load and save correctly during the deploy window.
  • All schema additions are additive. AutomationRun is a brand-new table; nothing reads from it yet outside the new endpoints.
  • Disconnect UX caveat: when the user clicks Disconnect on a provider, hasKey: false saves but the underlying API key string is preserved in tenant.settings.<provider>ApiKey (because the route preserves on empty per preserveApiKeys()). The hasKey boolean correctly reads false on subsequent loads; the residual string is invisible to the UI. A proper explicit-clear path is a follow-up.

noteSynthetic-condition guard

The flow editor blocks Save with a toast when conditions are empty rather than smuggling a no-op {field:'_', operator:'is_set'} row past ConditionGroupSchema.conditions.min(1). Caught during integration review.

noteOut of scope (deferred to follow-ups)

  • AI test prompt comparison panel (scope doc §4.2 Q3 — defer to PR-E).
  • Per-provider usage attribution on TenantUsage (needs schema change; today the combined figure shows on the default provider card only).
  • Worker-side AutomationRun writes on real fires (worker untouched this PR; production fires still happen, just without history). Plumbing is intentionally one-sided: API can write, worker can't yet.
  • Real Slack integration delivery for the send_notification action (table doesn't exist).
  • Action drag-reorder within the canvas (action order is array-order on read).
  • Multi-branch condition paths (variant 12 single "Match" path only — branching is a v2).

v5.3.0-alpha.40

Tier-2 settings wave 2 — business-hours + automations + 4 PR-C TODO closures (PR-D of 5). See PR #98. Closes 3 of the 4 remaining settings-core.html preview surfaces (variants 4, 11) plus all 4 TODO(PR-D) markers from PR-C. AI redesign (variant 10) + flow editor (variant 12) deferred to PR-D-flow per scope doc Option 2.

noteAdded — Schema (3 columns + 1 index)

  • User.defaultReplyVisibility (text default 'reply')
  • NotificationPreference.eventChannels (jsonb nullable; null = legacy fallback)
  • Automation.runCount / errorCount (int default 0), lastFiredAt (timestamp), position (int default 0, backfilled via row_number() OVER (PARTITION BY tenantId ORDER BY createdAt))
  • Composite index Automation_tenantId_position_idx for the list query

Migration: 20260511010000_pr_d_settings_foundation.

noteAdded — Zod schemas (4 new + 2 extended) in `@helpmesh/core`

  • BusinessHoursSchema extended: dateEnd on holidays + slaPause/autoReply/showOnlineBadge optional booleans. Split-shifts deferred (would have broken computeDeadline.ts).
  • AiSettingsSchema (NEW) — providers, defaultProvider, budgetCapMonthly, features, autoDraft, guardrails. Ships now so PR-D-flow's AI route has a contract; no UI/API consumes it yet.
  • NotificationEventChannelsSchema (NEW) — full matrix shape, all required.
  • AutomationReorderSchema (NEW) — { ids: cuid[].min(1).max(500) }.
  • UserProfileSchema extended: accepts defaultReplyVisibility: 'reply' | 'note'.

noteAdded — APIs (5 new + 2 extended)

  • GET/PATCH /api/v1/settings/business-hours — wrapper around tenant businessHours; PATCH ADMIN, replace-not-merge.
  • PATCH /api/v1/automations/reorder — atomic transaction; tenant-scoped pre-flight count detects cross-tenant id smuggling.
  • GET /api/v1/me/availability/capacity{ used, max, autoPause }; counts open threads (status OPEN/PENDING).
  • GET /api/v1/me/availability/calendar — 90-day-capped strip; precedence ooo > holiday > working > closed.
  • PATCH /api/v1/users/me extended for defaultReplyVisibility.
  • GET/PATCH /api/v1/users/me/notifications extended for eventChannels (null clears via Prisma.JsonNull).

noteAI route REVERTED intentionally

Initial pass restructured PATCH validation to AiSettingsSchema + tightened AGENT→ADMIN, but writing only to settings.ai broke the existing /settings/ai page (still sends flat keys). Ships intact in PR-D-flow alongside the redesigned AI page.

noteUI built in parallel by 3 sub-agents

  • `/settings/business-hours` (NEW, 549 lines, variant 4) — time zone, weekly schedule, holidays with multi-day support, off-hours behavior toggles. UAE 2026 holiday import deferred.
  • `/settings/automations` (REDESIGNED, 501 lines, variant 11) — native HTML5 drag-drop reorder (no @dnd-kit dep), per-row run-count/last-fired/error pill, isActive toggle (immediate PATCH), delete with confirm. Inline create form removed.
  • TODO closures (3 files): real eventChannels matrix in notifications (Slack column added but DISABLED with tooltip — SlackIntegration table doesn't exist), defaultReplyVisibility persists in profile, capacity-used wired in availability banner, 60-day OOO calendar strip (CSS grid; green/gray/amber/red).

One integration fix post-agents: removed dead toggleChannel + ChannelKey from notifications.

noteBackwards compatibility

  • All schema additions nullable or have safe defaults; no existing row behaviour changes.
  • Notification matrix: when eventChannels is null, workers fall back to (events × channels) cross-product. Front-end writes the new shape on first save.
  • Automations: position backfilled from createdAt order — list order is stable.
  • AI route untouched; existing /settings/ai page works exactly as before.

noteOut of scope (deferred to PR-D-flow)

  • AI page redesign + AI route restructuring (variant 10)
  • Flow editor canvas (variant 12) + AutomationRun + /runs + /test-fire
  • BusinessHoursSchema split-shifts
  • Bundled UAE 2026 holiday data
  • Functional Slack DM delivery (column DISABLED for now)
  • Per-provider AI usage stat split

noteQuality gates

  • typecheck ✓ across all 15 packages
  • lint ✓ (only pre-existing warnings)
  • core 423 / db 222 / web 634 tests all pass (web was 526 = +108)
  • repo build ✓ (36s)

noteBuilt in parallel

1 API agent + 3 UI agents off centralized schema + APIs. Pattern proven in PR-C.

v5.3.0-alpha.39

Contract tests for PR-C routes + PR-D scope doc. See PR #97.

noteTests (+56)

  • users/me/route.contract.test.ts — 15 cases on the extended PATCH (UserProfileSchema): new fields accepted, empty-string-clears semantics, length bounds, silent stripping of email/role/tenantId.
  • me/availability/availability.contract.test.ts — 20 cases (UserAvailabilitySchema): partial bodies, ISO datetime parsing, workingHours empty/null/per-day shapes, capacity bounds, autoPause boolean, silent stripping.
  • csat/config/config.contract.test.ts — 21 cases (CsatConfigSchema): PATCH-as-PUT with empty body, scale + delayUnit enum validation, delayValue [0,720] + recentSurveyWindowDays [1,180] bounds, ADMIN-only verification.
  • All three import schemas directly from @helpmesh/core — no schema duplication. Suite runs in 2.44s.

docsDocs

  • docs/plans/pr-d-scope.md (263 lines) — covers the next tier-2 wave. 4 primary surfaces (business hours, AI, automations list, flow editor) + 4 PR-C TODO closures. Recommended sequence: split into PR-D (schema + APIs + 3 surfaces + 4 TODO closures, ~5d) and PR-D-flow (canvas editor + AutomationRun + /runs + /test-fire, ~4d). Honest decisions captured (per-event-channel JSON column vs relational, vertical-only flow render, test-fire dry-run only, AI route auth tightening).

noteQuality gates

  • typecheck ✓ across all 15 packages
  • lint ✓ (only pre-existing warnings)
  • web tests: 582 pass (was 526; +56 new)
  • Code-only PR — no schema, no migration. Ships via standard tag-push deploy.

v5.3.0-alpha.38

Tier-2 settings batch — CSAT config + Notifications + Profile + Availability/OOO (PR-C of 5). See PR #96. Closes 4 of the 18 surfaces from settings-core.html.

noteAdded — Schema (User + Tenant)

  • User.displayName, User.personalTimezone — profile niceties.
  • User.outOfOfficeUntil, User.outOfOfficeMessage — single forward-looking OOO flag.
  • User.workingHours (Json) — per-user override of tenant business hours, validated by BusinessHoursSchema.
  • User.maxConcurrentTickets (Int default 0; 0 = unlimited), User.autoPauseAtCapacity (Bool default false) — capacity-aware round-robin foundation.
  • Tenant.csatConfig (Json default {}) — survey behavior config (delay, scale, skip rules, per-channel toggles).
  • Partial index on User.outOfOfficeUntil for the round-robin OOO filter.

noteMigration — `20260511000000_user_availability_and_csat_config`

  • 7 new columns on User + 1 on Tenant. All nullable or have safe defaults — no backfill needed.

noteAdded — Zod schemas in `@helpmesh/core`

  • UserAvailabilitySchema — OOO + working hours + capacity.
  • UserProfileSchema — name + displayName + avatarUrl + personalTimezone + replySignature; never accepts email/role/tenantId.
  • CsatConfigSchema — enabled + delay + scale + skip rules + per-channel toggles. Enums CsatScale + CsatDelayUnit exported.

noteAdded — APIs

  • PATCH /api/v1/users/me extended: now accepts name, displayName, avatarUrl, personalTimezone (was replySignature only). Email/role still unwritable.
  • GET/PATCH /api/v1/me/availability — new. Self-managed, session-scoped.
  • GET/PATCH /api/v1/csat/config — new. PATCH is ADMIN-only. Full-shape replace (not partial-merge) to keep the contract simple.

noteUI redesigns (matching `docs/ui-previews/settings-core.html`)

  • `/settings/csat` — REWRITTEN as a config page (variant 9). Old stats dashboard concept moved out (a future /reports/csat-trends page picks it up). Sections: master switch, "When to ask", "Skip rules", "Channel-specific behavior" (Slack row disabled per preview).
  • `/settings/notifications` — Redesigned with locked PR-A primitives (variant 13). Sections: quick-toggle card, 3-channel × 6-event matrix, quiet hours.
  • `/settings/profile` — Redesigned (variant 14). Identity + Personal preferences fully wired. Security + Personal API tokens stubbed with TODOs (need new APIs).
  • `/settings/availability` — NEW page (variant 15). Status banner, working-hours editor (7 days), capacity + auto-pause, single-OOO schedule/end UI.

noteSchema-mismatches handled (TODOs in code)

  • Notifications: per-event-per-channel granularity → cells share column-master state. Slack DM column dropped (no Slack channel in API).
  • Notifications: 8-event preview list → only the 6 the API persists.
  • Profile: Security (MFA, sessions) + Personal API tokens stubbed.
  • Profile: Default reply visibility stored locally only.
  • Availability: 60-day calendar visualizer + capacity-used count + historical OOO list deferred (single-flag data model).

noteBackwards compatibility

  • Schema: only additions; existing rows behave as "inherit defaults".
  • PATCH /api/v1/users/me previously accepted only replySignature. New fields all optional. Existing callers unaffected.
  • Old CSAT data endpoints (/api/v1/csat, /api/v1/csat/stats) still respond. A future /reports/csat-trends page can re-render them.

noteBuilt in parallel

4 sub-agents redesigned the 4 pages in parallel against centralized schema + APIs. ~1900 lines of new/redesigned UI.

noteQuality gates

  • typecheck ✓ across all 15 packages
  • lint ✓ (only pre-existing warnings)
  • core 423 / db 222 / web 526 tests all pass
  • repo build ✓ (37s)

v5.3.0-alpha.37

Worker MJML rendering + read-flip from `SystemEmailTemplate` (PR2.4b of 4). See PR #95.

noteAdded — Worker reads from the table

  • apps/worker/src/processors/customer-welcome.ts now reads welcome-email content from the SystemEmailTemplate table (PR2.4a) instead of tenant.settings.customerWelcomeEmail JSON.
  • New resolveWelcomeTemplate({tenantId, customerLocale, tenantDefaultLocale, legacyJson}) returns {subject, body, source, locale}. source is one of table / json-fallback / defaults for telemetry.

noteAdded — Locale waterfall

  • customer.locale → tenant.defaultLocale → 'en'. Single Prisma query with IN [...] filter, then pick by candidate order to honor priority. Duplicate locales deduped before the query. enabled=true filter at the query layer; disabled rows are invisible.

noteAdded — Backfill-on-cache-miss

  • Cache miss for ALL three locale candidates → fall back to legacy JSON shape and lazy-upsert a row for the locale that won. The next send for the same (tenantId, locale) hits the table path. This closes the gap from PR2.4a's non-blocking dual-write.
  • Legacy flat JSON shape (pre-PR2.2 subject+body at root) is synthesized as an en row.
  • Backfill upsert failures are logged + non-fatal — the send still goes out using the JSON-derived content.

noteAdded — MJML rendering (worker-only)

  • New apps/worker/src/lib/render-email-html.ts. Async function returns {html, mjmlRendered}.
  • Three input shapes: MJML markup → compiled via mjml@5 (validationLevel: 'soft'); plain HTML (Tiptap output) → pass-through verbatim; plaintext → escape + paragraph-wrap (same logic the previous toHtml() used).
  • MJML compile errors fall back to a sanitized text-only render so the send still goes out.
  • Worker-only — keeps the ~3MB mjml@5 dependency out of the web bundle. Build verified: web bundle size unchanged.

noteTests (+22)

  • render-email-html.test.ts — 10 cases: MJML happy path, uppercase + leading whitespace detection, compile-error fallback, HTML pass-through (incl. merge tags), plaintext wrap (paragraph breaks, single-newline → <br>, char escaping, inline styles).
  • customer-welcome.test.ts — 12 new cases: customer locale wins, fall back to tenant default, fall back to en, locale candidate dedup, enabled=true filter, JSON per-locale fallback, lazy backfill triggered on cache miss, legacy flat JSON synthesis, defaults branch, null-on-disabled, swallow-and-warn on backfill failure.
  • 8 existing resolveWelcomeFromHeader tests untouched and still pass.

noteDependencies

  • mjml@5.2.0 added to apps/worker (~3MB unpacked). Async API (mjml2html returns Promise<{html, json, errors}>).
  • @types/mjml not installed — mjml@5 ships its own types.
  • No web-side changes; web bundle unaffected.

noteBackwards compatibility

  • Legacy tenant.settings.customerWelcomeEmail JSON shape is still read as the cache-miss fallback. PR2.4c will retire it entirely.
  • The dual-write helper from PR2.4a is still active in PATCH /api/v1/tenant — it now races with the worker's lazy backfill, but upsert semantics make it idempotent.
  • No schema changes — code-only PR. Ships via the standard tag-push deploy. No migration required.

noteOut of scope (PR2.4c)

  • Remove the JSON shape from tenant.settings.customerWelcomeEmail
  • Drop the dual-write helper in PATCH /api/v1/tenant
  • Drop the JSON-fallback branch in resolveWelcomeTemplate

noteQuality gates

  • typecheck ✓ across all 15 packages
  • lint ✓ (only pre-existing warnings)
  • worker tests: 55 pass (was 33)
  • repo build ✓ (33s)

v5.3.0-alpha.36

`SystemEmailTemplate` Prisma model + dual-write — opens migration window for system-triggered emails (PR2.4a of 4). See PR #94.

noteAdded — `SystemEmailTemplate` model

  • New table at packages/db/prisma/schema.prisma. One row per (tenantId, key, locale) with enabled, subject, preheader?, body. Composite unique on the three-tuple. Cascade delete on tenant.
  • key column accepts customer-welcome, agent-invite, or password-reset (validated by SystemEmailTemplateKeySchema at the API layer; the DB stays generic for future expansion).
  • Distinct from the existing user-authored tenant.settings.emailTemplates JSON which holds agent reply templates — different concern, different namespace.

noteMigration — `20260510140000_system_email_template`

  • CREATE TABLE + composite UNIQUE + tenant FK.
  • Backfills customer-welcome rows from each tenant's existing tenant.settings.customerWelcomeEmail JSON, locale=en (the only locale the legacy shape carried). Tenants with no JSON get no row.
  • agent-invite and password-reset rows are NOT pre-seeded — those consumers will lazy-create on first use.

noteAdded — Zod validation in `@helpmesh/core`

  • SystemEmailTemplateSchema (full create) and SystemEmailTemplateUpdateSchema (partial, key+locale required).
  • BCP-47 locale shape, RFC 5322 subject cap (998 chars), 50KB body cap, optional 200-char preheader.

noteAdded — Dual-write helper (non-blocking)

  • New mirrorCustomerWelcomeToTable helper at apps/web/src/app/api/v1/tenant/system-email-template-dual-write.ts wired into PATCH /api/v1/tenant. When customerWelcomeEmail is in the body, mirrors each filled locale into a SystemEmailTemplate row.
  • Failures don't block the JSON write — the legacy JSON shape stays authoritative through alpha.36, so a mirror miss only logs a warning. PR2.4b's read-flip will read-then-backfill on cache miss to close any gap.
  • Handles the legacy flat JSON shape (pre-PR2.2 subject+body at the root) by synthesizing an en row.
  • Defends against malformed locale keys from bad client payloads.

noteTenant isolation

  • SystemEmailTemplate added to TENANT_SCOPED_MODELS in the AsyncLocalStorage middleware so tenantId is auto-injected on every Prisma op.
  • Coverage suite expanded; also caught a pre-existing gap where BulkActionRun was in middleware but missing from the test fixture (fixed in the same diff to keep them in sync).

noteTests (+37)

  • @helpmesh/core — 29 Zod schema cases covering key enum, BCP-47 locale shape, length caps, partial-update shape.
  • @helpmesh/db — tenant-scope assertions auto-expanded across the new model (× 9 ops × 2 newly-added rows in the fixture).
  • @helpmesh/web — 8 dual-write helper cases (mock prisma): per-locale upsert, enabled flag persisted, legacy flat-shape synthesis, invalid-locale defense, preheader nulling, swallow-and-warn on upsert failure, no-op on empty payload.

noteBackwards compatibility

  • Reads still come from tenant.settings.customerWelcomeEmail JSON. PR2.4b will flip reads to the new table.
  • The worker (apps/worker/src/processors/customer-welcome.ts) is untouched — welcome-email sends continue to work exactly as in alpha.35.
  • The mirror helper degrades safely: if the table doesn't exist or upsert errors, the JSON write still succeeds.

noteOut of scope (deferred)

  • PR2.4b — MJML rendering in the worker + flip reads from JSON → table.
  • PR2.4c — retire the JSON shape entirely.

noteDeploy ordering

The migration ran via the helpmesh-migrate-prod ECS one-shot task after deploy.yml pushed the new :latest worker image. This is the canonical HelpMesh pattern — deploy.yml does NOT run migrations, and the migrate-prod task uses the :latest worker image so it must run after the new image is pushed.

noteQuality gates

  • typecheck ✓ across all 15 packages
  • lint ✓ (only pre-existing warnings)
  • core 423 / db 222 / web 526 tests all pass
  • build ✓ (47s)

v5.3.0-alpha.35

Hotfix — remove legacy `next.config.mjs` redirects shadowing the PR-A and PR1 redesigns. See PR #93.

fixFixed

  • apps/web/next.config.mjs carried 25 redirects from a pre-redesign "settings consolidation" that bounced flat /settings/* paths into a tabbed group wrapper (e.g. /settings/general/settings/workspace?tab=general). Next.js processes config redirects BEFORE page components, so these silently overrode:
  • Removed all 25. Each canonical /settings/* path now serves its own redesigned page directly. Page-level redirect() calls in page components are unaffected by config rules and continue to work.

- PR-A's redesigned /settings/general and /settings/localization (alpha.32) - PR1's redirect() stub at /settings/welcome-email/settings/email-templates/welcome (alpha.34)

noteBackwards compatibility

  • The legacy tabbed group pages (/settings/workspace, /settings/channels, /settings/people, /settings/content, /settings/automation, /settings/developer, /settings/notifications-audit) still exist on disk and remain reachable directly. Only the auto-bouncing stops.
  • Bookmarks to /settings/workspace?tab=general keep working unchanged.
  • Bookmarks to /settings/general now land on the redesigned page directly (the desired behavior since alpha.32).

noteDiscovered

During alpha.34 prod smoke test: /settings/welcome-email returned 307 → /settings/workspace?tab=welcome-email instead of the new welcome-email path. Investigating revealed /settings/general and /settings/localization were also being shadowed.

noteQuality gates

  • typecheck ✓
  • lint ✓ (only pre-existing warnings)
  • 518 tests still pass

v5.3.0-alpha.34

Welcome-email redesign batch + CI workflow dedupe. Five-PR batch covering the full visual lift of /settings/email-templates/welcome, locale-aware authoring (en/ar/fr with source fallback), Tiptap rich-text body, send-test endpoint, and a one-line CI fix to disambiguate the two Deploy to Production workflows. See PRs #88, #89, #90, #91, #92.

noteAdded — `/settings/email-templates/welcome` (PR1 → PR2.3)

  • Full visual liftSettingsSection / SettingsFormRow / SettingsSaveBar primitives from PR-A; trigger toggle + sender + message + live preview sections; desktop/mobile preview toggle.
  • Route move — page lives at /settings/email-templates/welcome to match the sub-nav PR-A shipped (alpha.32). The legacy /settings/welcome-email path is now a redirect() stub so existing bookmarks resolve cleanly.
  • Tiptap body editor — bold / italic / lists / link toolbar + {{merge.tag}} insert dropdown. Direction-aware (rtl when active locale = ar). Persists sanitized HTML; legacy plaintext bodies migrate forward at read time.
  • Locale tabs — English (source) + Arabic + French. Each locale stores its own subject / preheader / body. Empty fields fall back to the source at send time per the published resolveLocale() contract. Per-locale completeness dot beside each non-source tab.
  • Send-test endpointPOST /api/v1/email-templates/welcome/send-test (ADMIN+). Recipient locked to the authenticated session user — no caller-supplied recipient field. Rate-limited 5/hour/tenant. Audit-logged with locale + fellBackToSource + Postmark messageId. Wrapped in a 🧪 TEST banner so admins cannot confuse it with a customer-facing send.
  • `Send test` button in the Message section header — disabled while loading / dirty / in-flight.

noteAdded — `EmailSender.sendWelcomeTest()`

New method on packages/channels/src/email/outbound-sender.ts that takes the resolved (post-merge-tag, post-sanitize) subject + HTML body and sends via Postmark with Tag welcome-template-test, TrackOpens=false, TrackLinks='None'. Test sends don't pollute template analytics.

noteAdded — helpers + tests (+37 new)

  • render-merge-tags.ts (+ 7 vitest cases) — substitutes {{token}} / {{token.path}}; unknown tags preserved verbatim so authors notice typos in the live preview.
  • text-to-html.ts (+ 8 vitest cases) — legacy plaintext → HTML at read time. Paragraph breaks (\n\n) become <p>; single \n becomes <br>. Existing HTML passes through verbatim.
  • locale-helpers.ts (+ 10 vitest cases) — isLocaleFilled() (treats empty Tiptap output <p></p> as not filled) and resolveLocale() (per-field source fallback at send time).
  • build-locale.ts (+ 6 vitest cases) — extracts the legacy/new shape merge logic out of the send-test route so its contract gets dedicated coverage.
  • send-test.contract.test.ts (+ 6 vitest cases) — defends the recipient-lock + tenantId-strip security properties.

featureChanged

  • `CI`.github/workflows/deploy-prod.yml (manual workflow_dispatch lever) renamed to Manual Deploy to Production so gh run list --workflow="Deploy to Production" resolves uniquely to deploy.yml (the active tag-triggered workflow). Behavior unchanged. (PR #88)

noteBackwards compatibility

  • Persistence shape lives under existing tenant.settings.customerWelcomeEmail. The new code reads BOTH the legacy flat shape (subject, body) AND the new per-locale map (locales.en, .ar, .fr), and writes the new shape on save. Existing tenants keep their content.
  • Plaintext bodies lift cleanly into Tiptap via textToHtml() at read time.
  • Empty AR/FR locales always fall back to the English source at send time, so existing tenants who never author translations stay safe.

noteTenant isolation

  • welcome/send-test route uses apiHandler middleware (tenantId injected server-side) and prisma.tenant.findUnique scoped to the resolved tenantId.
  • The body schema is locked to { locale: 'en' | 'ar' | 'fr' } — caller-supplied to / email / recipient / cc / tenantId fields are silently stripped by Zod, asserted by an explicit contract test.

noteQuality gates

  • typecheck ✓ across all 15 packages
  • lint ✓ (only pre-existing warnings)
  • apps/web: 518 passed (+37 vs alpha.33's 481)
  • packages/db: 202 · core: 394 · auth: 4 · ai: 35 · billing: 3 · reporting: 19 · worker: 34 · sdk: 11

noteStack rationale

The PR2 work was deliberately split into 4 stacked PRs (Tiptap → locales → send-test → schema/MJML) so each landed as a separable unit. PR2.4 (EmailTemplate Prisma model + Prisma migration + MJML rendering) is deferred to its own session because the schema migration must deploy via the ECS one-shot worker BEFORE the consuming code ships.

v5.3.0-alpha.33

Settings — email-domain + email-channel summary pages (PR-B partial). Closes the dead-link risk created by PR-A's nav (alpha.32) where 'Email domain' and 'Email channel' entries 404'd. See PR #87.

featureAdded

  • `/settings/email-domain` — Custom email domain (BYOD) status card + link to /settings/inboxes#byod where DNS records + verify flow live.
  • `/settings/channels/email` — Email channel summary listing connected email inboxes with per-inbox configure links.

Both pages use the new SettingsSection / SettingsFormRow primitives from PR-A. Read-only entry points — they don't duplicate or replace the existing /settings/inboxes editor surface.

noteScope honesty

PR-B was originally scoped as 5 surfaces. The 3 visual-lift surfaces (Branding, Email templates, Welcome email) are deferred to a follow-up PR-B' — each existing TSX has per-section save handlers (logo S3 presigned upload, colors, white-label, reply-email signature, etc.) that don't cleanly collapse into the new unified dirty/save-bar pattern without restructuring end-to-end. That refactor needs its own focused session.

noteQuality gates

  • typecheck ✓ across all 15 packages
  • lint ✓ (only pre-existing warnings)
  • packages/db: 202 · core: 394 · auth: 4 · ai: 35 · billing: 3 · reporting: 19 · worker: 34 · sdk: 11 · apps/web: 481
  • build: 34.7s ✓

v5.3.0-alpha.32

Settings home + General + Localization redesign (PR-A of 5). First PR in the settings-core marathon. Establishes the design foundation every subsequent PR will reuse. See PR #86.

featureAdded

  • `/settings` redesignSetupProgress card (10-step workspace checklist with progress bar, auto-hides at 100%) + RecentlyVisited card (last 4 visited settings pages from localStorage) + 8 grouped category cards (Workspace · Channels · AI & automation · People & access · Content & tagging · Integrations · Privacy · Personal).
  • `GET /api/v1/settings/setup-progress` — derives 10 milestones from existing tenant state (no new tables). CSAT detected by *any* CSATResponse, WhatsApp by Inbox.channel='WHATSAPP', KB by published article.
  • 3 new shared primitives in apps/web/src/components/settings/SettingsSection, SettingsFormRow, SettingsSaveBar. Reused by all 4 follow-up settings PRs.
  • `useTrackSettingsVisit()` hook + pushRecent() pure helper for the recently-visited tracking.

featureChanged

  • `/settings/general` — stripped business hours + locale UI (those live in their own pages). Now focuses on workspace identity (name + slug) + defaults (timezone). Uses new primitives + dirty/save-bar pattern.
  • `/settings/localization` — same primitives. Three sections: Default language, Supported languages (toggle list with article counts + RTL badge), RTL preview (LTR vs Arabic side-by-side).
  • Settings layout sub-nav re-grouped into 8 sections matching the preview. Visual refresh: hm tokens, logical border-e (RTL-safe), opacity scaling on icons.
  • `/settings/workspace` tab wrapper — switched to default imports for general + localization (export shape changed in this PR).

noteTests (+11)

  • recently-visited.test.ts (5) — pushRecent helper: prepend, dedupe-and-move-to-front, cap at 4, default Date.now(), no mutation
  • setup-progress.contract.test.ts (6) — response shape: 0% / 100% / reject negative counts / reject percent>100 / reject empty step id-or-label / reject totalCount=0

noteTenant + a11y

  • setup-progress route through apiHandler — tenantId resolved server-side, never client input
  • Sub-nav aria-label="Settings" + aria-current="page" on active link
  • SettingsSaveBar role="status" + aria-live="polite"
  • All spacing in start-*/end-* / ms-*/me-* logical properties (RTL-safe)

noteQuality gates

  • typecheck ✓ across all 15 packages
  • lint ✓ (only pre-existing warnings)
  • packages/db: 202 · core: 394 · auth: 4 · ai: 35 · billing: 3 · reporting: 19 · worker: 34 · sdk: 11
  • apps/web: 481 passed (+11 new)
  • build: 39.5s ✓

noteMarathon plan

  • alpha.32 (this PR) — Settings home + General + Localization (3 surfaces)
  • PR-B — Branding + Custom email domain + Email channel + Email templates + Welcome editor (5 surfaces)
  • PR-C — CSAT + Notifications + Profile + Availability/OOO (4 surfaces)
  • PR-D — Business hours + AI model + Automations + Automation flow editor (4 surfaces)
  • PR-E — Data privacy + Billing (2 surfaces)

v5.3.0-alpha.31

Bulk-action engine — UI components + dev sandbox (PR4/4). Final PR in the bulk-action engine series. Ships the three user-facing components for the engine PR1–3 built. Components-only — the existing <BulkToolbar> in inbox stays untouched (replacement is PR5, separate, future). See PR #85.

featureAdded

  • `apps/web/src/components/agent/bulk/BulkActionToolbar.tsx` — bottom-of-screen overlay when 1+ threads selected. Mirrors auth-misc.html surface 5 toolbar. Buttons for every kind. Picker-required kinds (ASSIGN, SET_PRIORITY, ADD_LABEL, REMOVE_LABEL) call back to parent; CLOSE/REOPEN/UNASSIGN dispatch directly; DELETE opens DeleteConfirmDialog.
  • `apps/web/src/components/agent/bulk/BulkActionProgressModal.tsx` — opens after dispatch, polls GET /api/v1/bulk-actions/:id every 1500ms (setTimeout chain). Mirrors variants 5a/5b/5c. Stop button calls POST :id/stop. Retry-failed CTA in terminal-state footer. Idempotent re-open on the same runId resumes from current DB state.
  • `apps/web/src/components/agent/bulk/DeleteConfirmDialog.tsx` — type-to-confirm gate for DELETE_THREAD. Reuses isValidDeleteConfirmation() from @helpmesh/core so client + server agree on the canonical phrase.
  • `/dev/bulk-actions` — new dev-only sandbox route. NOT mounted in production navigation. Lets you paste Thread IDs, dispatch any kind through the live API, and watch the modal poll real DB state.

noteTests (+8)

  • bulk-action-helpers.test.tskindIsSimple covers all 8 kinds, shouldWarnIrreversible flags only DELETE_THREAD, headerForStatus + labelForKind produce documented copy for every status + kind.

noteTenant + a11y

  • All API calls use credentials: 'same-origin' so apiHandler resolves tenantId from session cookie — no client tenantId injection
  • Toolbar: role="region" + aria-label="Bulk actions"
  • Progress bar: role="progressbar" + aria-valuenow/min/max
  • DeleteConfirm: useId() for label-input pairing
  • Modal scrim: existing @helpmesh/ui Modal (Radix-based, focus trap + keyboard close)
  • All spacing in start-*/end-* logical properties (RTL-safe)

noteQuality gates

  • typecheck ✓ across all 15 packages
  • lint ✓ (only pre-existing warnings)
  • packages/db: 202 · core: 394 · auth: 4 · ai: 35 · billing: 3 · reporting: 19 · worker: 34 · sdk: 11
  • apps/web: 470 passed (+8)
  • build: 35.0s ✓

noteEngine series complete

  • alpha.28 (PR1): schemas + migration + 6 contract tests
  • alpha.29 (PR2): worker + queue + 8 kind apply functions + 23 tests
  • alpha.30 (PR3): REST API + RBAC + 9 isolation tests
  • alpha.31 (PR4): UI components + dev sandbox + 8 tests
  • Total: 4 PRs, 46 new tests, 8 kinds, 7 reversible, full audit + idempotency + tenant-isolation surface

noteWhat's NOT included (deferred)

  • Inbox migration — replacing <BulkToolbar> with the new toolbar is PR5
  • CSV export endpoint — deferred to PR3.5
  • Compensating actions for already-OK reversible items on stop — deferred to PR3.5

noteSmoke test after deploy

1. Visit /dev/bulk-actions in your authenticated session 2. Paste 1–3 thread IDs you own 3. Click "Close all" — modal opens, polls, completes 4. Verify thread.resolved OutboxEvents fire (existing webhook + realtime fanout works for free)

v5.3.0-alpha.30

Bulk-action engine — REST API (PR3/4). Code-only release that ships the public REST API on top of PR1's schemas (alpha.28) and PR2's worker (alpha.29). The UI (PR4) lands separately. See PR #84.

featureAdded

  • `POST /api/v1/bulk-actions` — create + dispatch a run. Validates kind from BULK_ACTION_KINDS, targetIds (1–1000 cap), per-kind params via Zod. Per-kind RBAC (DELETE_THREAD requires ADMIN, others AGENT+). DELETE_THREAD type-to-confirm phrase enforced (delete <N> threads, case-insensitive). Idempotency-key short-circuit returns existing run. Tenant ownership check on every target Thread before persistence. Single transaction creates BulkActionRun (PENDING) + N BulkActionRunItem rows + 7-day expiresAt. Enqueues bulk-action-dispatch job after commit.
  • `GET /api/v1/bulk-actions/:id` — poll progress. Returns the run row + per-status item counts. Tenant-scoped 404 on cross-tenant.
  • `POST /api/v1/bulk-actions/:id/stop` — request stop + rollback. Sets status=STOPPED, stoppedAt=now(). Idempotent. Returns 409 HM-BULK-STATUS-TERMINAL for COMPLETED/FAILED runs. Response includes reversible: kind !== 'DELETE_THREAD'.
  • `apps/web/src/lib/bulk-actions/queue.ts` — singleton Queue handle; enqueueBulkActionRun(runId) with dedup jobId, 3 retries, exp backoff.

noteTests (+9)

  • apps/web/src/app/api/v1/bulk-actions/__tests__/create.isolation.test.ts — schema rejects unknown kinds, oversized arrays (1000 cap), empty target ids, oversized idempotencyKey (128 cap), attacker-supplied tenantId/actorId. Asserts every documented kind from BULK_ACTION_KINDS parses OK.

noteTenant isolation

  • Body schema doesn't accept tenantId or actorId — tests assert these never appear in parsed output
  • Prisma client is tenant-scoped (middleware injects tenantId) — cross-tenant target Threads simply don't appear
  • GET/stop routes return 404 on cross-tenant (existing convention to avoid leaking existence)

noteQuality gates

  • typecheck ✓ across all 15 packages
  • lint ✓ (only pre-existing console-statement warnings)
  • packages/db: 202 · core: 394 · auth: 4 · ai: 35 · billing: 3 · reporting: 19 · worker: 34 · sdk: 11
  • apps/web: 462 passed (+9)
  • build: 33.8s ✓

noteWhat's next

  • PR4 — UI components: BulkActionToolbar, BulkActionProgressModal, DeleteConfirmDialog. Components-only first PR (no inbox migration yet — the existing <BulkToolbar> keeps working). PR5 (separate, future) does the inbox migration once we're confident.

v5.3.0-alpha.29

Bulk-action engine — queue + worker + OutboxEvent fan-out (PR2/4). Code-only release that wires the BullMQ queue, dispatcher, per-row executor, and per-kind apply functions for all 8 bulk-action kinds. No API surface yet — that lands in PR3 — so the new processors sit idle in the worker until then. See PR #83.

featureAdded

  • `packages/core/src/bulk-actions/` — string-literal mirror of the Prisma enums (kinds + statuses + tenant role) so this module stays out of the client bundle. Exports per-kind metadata (reversible, minRole, requiresTypeToConfirm, description) + Zod parameter schemas + validateBulkActionParams() + isValidDeleteConfirmation() (case-insensitive, whitespace-tolerant).
  • `apps/worker/src/processors/bulk-action-dispatch.ts` — receives {runId}, transitions PENDING run → RUNNING, fans out one execute-job per PENDING item. Idempotent via jobId: bulk-action-execute:<itemId> so retries are no-ops.
  • `apps/worker/src/processors/bulk-action-execute.ts` — receives {runId, itemId}. CAS-style claim (updateMany filtered by status='PENDING' so only one worker wins). Honours run.STOPPED. Applies the kind in a single Prisma transaction inside runWithTenant. Captures reversal snapshot ONLY for reversible kinds. Emits OutboxEvent per row. Auto-completes the run when all items terminal.
  • `apps/worker/src/processors/bulk-action-status.ts` — pure helpers (decideRunTransition, shouldDispatcherSkip) extracted for unit-testable state-machine logic.

noteAll 8 kinds implemented

ASSIGN, UNASSIGN, SET_PRIORITY, CLOSE, REOPEN, ADD_LABEL, REMOVE_LABEL, DELETE_THREAD. Every apply fn re-reads the entity inside the tx and re-checks tenantId === run.tenantId (defence-in-depth on top of the Prisma middleware).

noteTenant isolation (4 layers deep)

1. Prisma middleware auto-injects tenantId on every query 2. Executor wraps the apply in runWithTenant(run.tenantId) so AsyncLocalStorage is set 3. Each apply fn re-reads the target Thread/Label and asserts tenantId match 4. BulkActionRunItem rows are read using the parent runId (which is itself tenant-scoped) — no cross-tenant item access possible

noteQueue safety (per `.claude/rules/queue-safety.md`)

  • Idempotency: dispatcher dedup by jobId; executor uses CAS so concurrent workers never double-apply
  • Retry: 3 attempts, exp backoff (2s base). After 3 failures item lands FAILED with the error message visible in the UI per-row log (PR4)
  • Graceful shutdown: existing SIGTERM handling drains in-flight jobs; unfinished items stay PENDING for re-enqueue

noteReversibility matrix (locked in PR1, enforced here)

  • Reversible — ASSIGN / UNASSIGN / SET_PRIORITY / CLOSE / REOPEN / ADD_LABEL / REMOVE_LABEL → reversal snapshot captured, stop+rollback (PR3.5) issues compensating actions
  • NOT reversible — DELETE_THREAD → no reversal stored, stop only halts FUTURE rows

noteTests (+23 new)

  • packages/core/src/__tests__/bulk-actions.test.ts (+15) — kind metadata parity, RBAC (only DELETE_THREAD requires ADMIN + type-to-confirm), Zod param validation per kind, delete-confirmation phrase parsing
  • apps/worker/src/processors/bulk-action-status.test.ts (+8) — state machine: returns null while items in flight, COMPLETED when all terminal, preserves STOPPED, no double-transition

noteQuality gates

  • typecheck ✓ across all 15 packages
  • lint ✓ (only pre-existing console-statement warnings)
  • packages/core: 394 (+15) · apps/worker: 34 (+8) · packages/db: 202 · apps/web: 453
  • build: 34.6s ✓

noteBehaviour change

None visible to users. Workers now register two new job types (bulk-action-dispatch, bulk-action-execute) but no API surface dispatches them yet — the new processors will sit idle until PR3 ships the REST endpoints.

noteWhat's next

  • PR3 — REST API: POST /api/v1/bulk-actions, GET :id, POST :id/stop. Persists rows then calls Queue.add('bulk-action-dispatch', { runId }).
  • PR4 — UI: select-checkbox column on inbox/threads + bulk-action toolbar + progress modal (matches auth-misc.html surface 5).

v5.3.0-alpha.28

Bulk-action engine — schema foundation (PR1/4). Schema-only release that lays the groundwork for the "Bulk action progress modal" (auth-misc.html surface 5). Forward-compatible — no existing code reads these tables, so the migration applies safely before the worker + API land in subsequent PRs. See PR #82.

featureAdded

  • `model BulkActionRun` — one row per dispatched batch. Tenant-scoped (tenantId FK to Tenant, cascade delete). Indexed by (tenantId, createdAt) and (tenantId, status). Optional (tenantId, idempotencyKey) unique constraint so safe-retry returns the existing run instead of dispatching a duplicate.
  • `model BulkActionRunItem` — one row per affected entity. reversal JSONB stores the prior-state snapshot needed for stop+rollback (only populated for reversible kinds). Indexed by (runId, status) for the polling endpoint and entityId for impact-radius lookups.
  • 3 new enums: BulkActionKind (8 values across the reversibility matrix), BulkActionRunStatus (5 — PENDING/RUNNING/COMPLETED/STOPPED/FAILED), BulkActionItemStatus (5 — PENDING/RUNNING/OK/FAILED/SKIPPED).
  • Reverse relations on Tenant and User. The actor relation uses onDelete: SetNull so SUPER_ADMIN actions survive user delete (audit-trail integrity).

featureChanged

  • `packages/db/src/middleware/tenantScope.ts`BulkActionRun added to TENANT_SCOPED_MODELS (has tenantId directly, same shape as AuditLog). BulkActionRunItem is intentionally NOT scoped — tenant scope flows transitively via the parent run's tenantId, same pattern as Message → Thread. Adding it would make Prisma throw Unknown argument 'tenantId'.

noteMigration

  • 20260510031958_bulk_action_engine creates 2 tables + 3 enums + indexes + cascade FKs. Drift-free (stripped the unrelated ZoomIntegration.scopes DROP DEFAULT noise the generator picked up from local-DB drift, same pattern as alpha.26's Account migration).
  • Applied via ./scripts/deploy.sh prod --migrate (ECS one-shot worker task — same flow as alpha.26).

noteReversibility matrix (locked in here, drives PR2 worker + PR4 stop/rollback UX)

  • Reversible — ASSIGN / UNASSIGN / SET_PRIORITY / CLOSE / REOPEN / ADD_LABEL / REMOVE_LABEL → worker captures reversal snapshot per row; stop+rollback issues compensating actions for already-OK items.
  • NOT reversible — DELETE_THREAD → stop only halts FUTURE rows; already-deleted rows stay deleted. UI flags this with "abort future rows only" and requires type-to-confirm gate (matching the existing DeletionRequest two-admin pattern).

noteTests

  • 6 new contract tests in packages/db/src/__tests__/bulk-action-engine.test.ts lock the delegate surface (BulkActionRun + BulkActionRunItem reachable on both scoped + unscoped clients) and enum value parity (every kind from the reversibility matrix, every status the worker will need to write).

noteQuality gates

  • typecheck ✓ across all 15 packages
  • lint ✓ (only pre-existing console-statement warnings)
  • tests: db 202 (+6 new) · auth 4 · core 379 · ai 35 · billing 3 · reporting 19 · worker 26 · sdk 11 · apps/web 453
  • build: 32.6s ✓

noteWhat's next

  • PR2 (bulk-action BullMQ queue + dispatcher worker + per-row processor — ASSIGN/CLOSE/REOPEN first; LABEL/SET_PRIORITY/DELETE_THREAD in subsequent commits)
  • PR3 (REST API + RBAC + tenant-isolation contract tests)
  • PR4 (UI: select-checkbox column + bulk-action toolbar + progress modal + retry-failed flow + type-to-confirm gate)

v5.3.0-alpha.27

Universal command palette — tickets + customers + articles + settings + AI answer card. Closes the ⌘K surface from `docs/ui-previews/auth-misc.html` (Batch 11). Search now spans every searchable entity in the tenant; an AI summary card surfaces the most-likely pattern when the query is meaningful. Honours the feedback_no_shortcuts.md rule: no visible keyboard-shortcut chips anywhere in the palette or its trigger. The Cmd/Ctrl+K keyboard handler stays in place for power users; only the chip text was removed. > Originally drafted as alpha.22 on 2026-05-08 (PR #77) but parked overnight while alpha.23–.26 shipped ahead of it. Rebased onto current main and renumbered to alpha.27 on 2026-05-09. No content drift — same commit, same diff.

noteWhy this matters

The previous palette only searched threads and rendered hardcoded navigation/actions decorated with "G H / G I / N" shortcut chips. The preview locked in a richer flow — type-filter chips, four result groups, an AI answer card at the top, and Recent + Suggested empty state — all driven by real tenant-scoped data. This PR makes that real without faking a single row.

featureAdded

  • `apps/web/src/lib/search-kinds.ts` — pure helper resolveSearchKinds(raw) that parses the comma-separated kinds query-string into a Set of valid SearchKind values. Handles null / empty / "all" → every kind; drops unknown tokens silently; an explicit malformed input yields an empty Set rather than silently falling back to "everything". Exports the canonical SEARCH_KIND_VALUES tuple so the route, palette, and tests stay in lockstep.
  • `apps/web/src/lib/__tests__/search-kinds.test.ts` — 9 unit tests covering empty / "all" / single-kind / comma-separated / case-insensitive / unknown-token / fully-malformed / mutation-safety / fresh-Set semantics.
  • `apps/web/src/app/api/v1/search/ai-summary/route.ts` — new POST route that takes the query + the result snippets the palette already rendered and returns a one-sentence AI summary. Tenant-scoped via apiHandler; budget-checked via the existing CostTracker; no Prisma re-query so RBAC scoping cannot drift between this call and the search call that produced the rows. Returns { summary: null, reason: 'no_matches' } when there's nothing worth summarising so the UI can hide the card cleanly. Caps input rows (max 6 tickets, max 4 articles) so attackers cannot stuff the prompt context.
  • `apps/web/src/app/api/v1/search/__tests__/ai-summary.contract.test.ts` — 9 contract tests asserting the Zod schema enforces query length 4–500, ticket/article count caps (6/4), required fields per row, oversized-snippet rejection, and tenant-isolation contract (no tenantId injection).
  • `packages/ai/src/prompts/search-summary.ts` — new prompt module. The system prompt forbids the model from inventing data not in the supplied context, prefers patterns over single citations, caps output at one sentence (≤ 220 chars), and falls back to "No clear pattern across these results" when matches are thin.
  • `packages/ai/src/orchestrator.ts` — new summarizeSearchResults({ tenantId, query, tickets, articles }) method. Mirrors the budget-check + cost-track pattern of the existing summarizeThread. Returns null when budget is exceeded or the provider fails so callers can hide the AI card silently.
  • `apps/web/src/components/agent/CommandPaletteDialog.tsx` — extracted the palette out of (agent)/layout.tsx into a dedicated client component. New layout: filter chips (All / Tickets / Customers / Articles / Settings) with live counts; AI answer card at the top (debounced 600ms, fires only when query length ≥ 4 and at least one DB match came back, never on empty queries); four result groups with kind-appropriate row chrome; Recent + Suggested empty state seeded from localStorage (helpmesh:cmdk:recent, capped at 6 entries); No-results state with two action CTAs ("Create ticket with this", "Browse help center"). Skeleton matches the loaded shape so loading→loaded doesn't reflow. No <CommandShortcut> chips. No "(⌘K)" placeholder hint.

featureChanged

  • `apps/web/src/app/api/v1/search/route.ts` — extended the universal search endpoint. Same GET /api/v1/search?q=... path; new kinds and limit query params. Now searches threads (existing), customers (new — Customer.name / email ILIKE, sorted by lastSeenAt, returns tier + open thread count), published articles (new — title/body ILIKE on Article.status='PUBLISHED', returns a previewSnippet cropped around the match), and a static settings/actions catalog (no DB hit, filtered by query substring on label or keywords). Backwards-compatible: the response still includes threads[] with the same field set the original caller reads. Per-kind limit defaults to 5 (max 25) so the palette stays scannable.
  • `apps/web/src/app/(agent)/layout.tsx` — replaced the inline CommandPaletteDialog (~150 LOC) with the new component. Removed unused imports (Modal, ModalContent, CommandPalette, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandShortcut, useRef, Loader2, MessageSquare, Home, BarChart3, BookOpen, Inbox, Plus, StatusPill). The header search trigger is now a <button> (was a <div onClick>) with proper aria-label, dropped the visible "⌘K" <kbd> chip, and updated the placeholder text from "Search tickets, contacts..." to "Search tickets, customers, articles, settings…".
  • `apps/web/src/app/dev/kitchen-sink/page.tsx` — dropped the "(⌘K)" placeholder hint from the design-system showcase too. The CommandShortcut primitive remains in @helpmesh/ui for catalog completeness; production usage is gone.

noteQuality gates

  • Typecheck: clean across all 15 packages
  • Lint: clean (only pre-existing console-statement warnings)
  • Tests: +18 new (9 search-kinds + 9 ai-summary contract). Re-run on rebased branch verifies no regression against alpha.26 baseline.
  • Build: Next.js production build succeeds
  • Tenant isolation: contract test asserts the AI-summary schema doesn't accept a tenantId injection; the runtime route reads tenantId from apiHandler context only, never from body. Search route's customers + articles queries scope every Prisma where-clause through tenantId.
  • a11y: header search trigger upgraded from <div onClick> to <button aria-label>; AI answer card icons marked decorative; modal scrim handled by the existing Modal primitive; filter chips are real <button> elements (keyboard-reachable).
  • RTL: start-* / end-* logical properties throughout; no ml-*/mr-*/pl-*/pr-* introduced.

noteOut of scope (deferred)

The auth-misc preview also showed a Bulk action progress modal. The infrastructure to back it (BulkActionRun/BulkActionRunItem schemas, bulk-action BullMQ queue, per-row workers, OutboxEvent emission, audit + rollback semantics, CSV export) doesn't exist yet — that's the next initiative, scoped in project_todo_bulk_action_engine.md. With this PR shipped, auth-misc.html is fully closed for the surfaces the codebase actually backs (4 of 5 — login + reset-password + 2FA + ⌘K).

v5.3.0-alpha.26

Google OAuth fix. Adds the Account model that NextAuth's PrismaAdapter requires for OAuth provider linking. Since rev :28 went live on 2026-05-08 with GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET injected, every Google sign-in click had been throwing AdapterError: TypeError: Cannot read properties of undefined (reading 'findUnique') because p.account.findUnique resolves on undefined when the model isn't in the schema. The earlier (2026-05-08) diagnosis blamed the $extends tenant-scope wrapper — that turned out to be a red herring; the actual root cause was the missing Account model.

noteSchema

  • New model `Account` with the standard NextAuth fields: provider, providerAccountId, type, refresh_token, access_token, expires_at, token_type, scope, id_token, session_state. Unique (provider, providerAccountId). Cascade FK to User. Indexed by userId.
  • `User.oauthAccounts: Account[]` relation added.
  • `Account` + `VerificationToken` added to NON_TENANT_MODELS in tenantScope.ts so intent is explicit (NextAuth-owned tables, never tenant-scoped — same membership model as User and Session).
  • Migration 20260509133922_add_account_for_oauth_adapter is forward-compatible: existing code does not query Account, so applying it before the new code rolls is safe.

noteCode

  • `packages/db/src/client.ts` — exports prismaUnscoped: a raw PrismaClient instance without the $extends tenant-scope wrapper. Defensive hygiene per Auth.js docs ("use a raw Prisma client for the adapter").
  • `packages/auth/src/config.ts`PrismaAdapter now receives prismaUnscoped instead of the wrapped prisma. App code paths still import prisma for tenant-scoped queries; only the adapter switches.
  • 4 new contract tests at packages/db/src/__tests__/prisma-unscoped.test.ts lock the adapter-required delegate surface (user, account create/delete/findUnique/findFirst, session, verificationToken).

noteDeploy

1. PR #81 merged to main 2. ./scripts/deploy.sh prod --migrate built alpha.26 images, applied the Account migration via ECS one-shot worker task, redeployed both services 3. ECS service was already pinned at task def rev :28 with the GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET Secrets Manager entries — no env changes 4. Smoke-tested GET /api/auth/providers (returns google + credentials) + manual Google sign-in roundtrip on app.helpmesh.io

v5.3.0-alpha.25

Service-worker self-uninstall. A latent service worker (/sw.js, helpmesh-v1) was caching static assets cache-first and HTML on the default branch. Long-running Safari/Chrome clients would render unstyled pages because cached HTML referenced chunk hashes that no longer existed in newer builds. Caught 2026-05-08 when the user's regular Safari served bare HTML at helpmesh.io/ while a Private window on the same machine rendered the styled site correctly. Diagnosed by spotting the SW registration + cache-first strategy; fixed by replacing sw.js with a self-uninstall shim and removing the registration component so future builds don't re-register a worker.

noteWhy the SW had to go

  • Static assets cache-first — old .js/.css chunks cached forever; HTML always re-fetched but pointed at hashes the SW didn't have
  • Default-case caching everything else — including HTML responses on the default branch, which Cache-Control: no-cache, no-store from the server explicitly told browsers NOT to do
  • No version bumpinghelpmesh-v1 cache name never changed across builds, so old caches survived every deploy
  • No PWA capability actually being usedmanifest.json was registered but no app surface depends on offline behaviour

The previous worker: The fix is uniform: stop serving a worker that intercepts requests. Standard browser HTTP cache + Cache-Control headers handle every case correctly without the SW middleware.

featureChanged

  • `apps/web/public/sw.js` — replaced helpmesh-v1 with a deactivation shim. On install: skipWaiting(). On activate: deletes every cache entry the SW ever created, calls self.clients.claim(), calls self.registration.unregister(), then iterates every controlled window client and calls client.navigate(client.url) to force a fresh fetch from network. No `fetch` handler — once activated and unregistered, requests pass through to the standard browser stack.
  • `apps/web/src/app/layout.tsx` — removed the ServiceWorkerRegistration import + <ServiceWorkerRegistration /> mount. Replaced with an inline comment pointing at sw.js for the explanation. Future builds never register a worker, so the deactivation shim only runs once per existing client and then quietly disappears.
  • `apps/web/src/components/shared/ServiceWorkerRegistration.tsx` — deleted (was the only consumer; per CLAUDE.md, unused files are removed entirely rather than left behind).

noteRecovery flow per user

1. User visits any page on helpmesh.io 2. Browser auto-checks for SW updates (standard SW behaviour — happens on every navigation) 3. Browser sees the new sw.js content differs byte-by-byte from the cached helpmesh-v1 worker → fetches new SW 4. New SW's install runs → skipWaiting() immediately activates it 5. New SW's activate runs → wipes all caches, unregisters, force-reloads the tab 6. Tab reloads from network with no SW interception → fresh HTML + fresh chunks → page renders correctly 7. Subsequent navigations have no SW at all (it unregistered itself) Total user-visible impact: one extra page reload the first time they visit after this deploy, then back to normal.

noteQuality gates

  • Syntax: node --check apps/web/public/sw.js passes
  • Typecheck: clean across all 15 packages
  • Lint: clean (only pre-existing console-statement warnings)
  • Tests: 435/435 passing (no test changes — SW doesn't have a unit-testable surface in node-env vitest)
  • Build: Next.js production build succeeds in 32s

noteRisk assessment

  • Existing clients with the helpmesh-v1 worker: forced reload on next visit, then recovered.
  • New clients (visiting for the first time after this ships): never register a worker; standard HTTP cache only.
  • Offline support: was never working in practice (SW didn't cache navigation HTML); no regression.
  • PWA install: manifest.json still served, theme-color/apple-touch-icon meta still present. Users can still "Add to home screen" — they just won't have an offline shell. No real-world flow depended on the SW being registered.

noteMemory note

This explains the "blank page" + "Server error 500" + "unstyled HTML" reports earlier in the 2026-05-08 marathon. They were all symptoms of the same SW caching issue, NOT distinct bugs in alpha.21/22/23/24. Earlier rollbacks (alpha.21 → alpha.20, alpha.20 → alpha.18) didn't fix the user's experience because the SW kept serving stale chunks regardless of which image ECS was running.

v5.3.0-alpha.24

Security fix: `/home` (and 4 other agent routes) were missing from the middleware auth gate. Anonymous visitors could land on the dashboard chrome with skeleton cards rendered (every API call 401'd, but the page shell didn't redirect to /login). Caught 2026-05-08 in a Private Browsing screenshot. No data leak — the API layer's tenant-scoping kept actual data behind 401s — but the visible chrome was unintended.

noteWhy this matters

The agent route group expanded since middleware was last touched. New surfaces shipped (/home in v3.x, /customers in v4.x, /admin super-admin chrome in v5.0, /dev developer tooling, /knowledge post-Phase-6) — none made it into PROTECTED_PREFIXES. The auth gate inside (agent)/layout.tsx is client-side via `useSession`, which doesn't redirect — it just renders chrome with data: undefined until the session resolves (or never, for anonymous users). API calls 401, panels stay in error state, but the chrome is visible. The fix gates these surfaces at the edge so anonymous traffic redirects to /login before the layout renders.

featureAdded

  • `apps/web/src/middleware.ts` — exported PROTECTED_PREFIXES (was previously module-private) and a new isProtectedPath(pathname, prefixes?) helper. The helper requires either an exact prefix match OR a / separator after the prefix, so a fictitious public route like /admin-public doesn't get accidentally caught by the /admin prefix.
  • `apps/web/src/__tests__/middleware-auth-gate.test.ts` — 9 unit tests covering: every documented agent surface gates, every sub-route gates, marketing/auth/portal routes do NOT gate, prefix-substring routes do NOT gate (/admin-public, /customer-stories, /inboxes-help, /homepage), and the canonical PROTECTED_PREFIXES list is asserted against to defend future PRs from silently dropping a prefix.

featureChanged

  • `apps/web/src/middleware.ts` — extended PROTECTED_PREFIXES from ['/inbox', '/reports', '/settings', '/onboarding'] (4 entries) to ['/home', '/inbox', '/customers', '/reports', '/knowledge', '/settings', '/onboarding', '/dev', '/admin'] (9 entries). The middleware body now calls isProtectedPath(pathname) instead of the inline .some(p => pathname.startsWith(p)) so the same logic backs the unit tests.

noteQuality gates

  • Typecheck: clean across all 15 packages
  • Lint: clean (only pre-existing console-statement warnings)
  • Tests: 435/435 passing (+9 new)
  • Build: Next.js production build succeeds in 31s

noteBackwards compatibility

  • Customer-facing /portal is intentionally NOT in PROTECTED_PREFIXES — it uses its own widget-session model (/api/widget/v1/sessions) and runs separate from agent auth. Anonymous customers still reach the portal as before.
  • Marketing surfaces (/, /pricing, /features, /blog, /docs, /legal, etc.) are unchanged — none of them match a protected prefix.
  • Authenticated agents see no behaviour change — the redirect rule only fires for anonymous traffic.

noteHistory note

This bug was pre-existing, not introduced by the dashboard restyle (alpha.19/20/21). The earlier marathon's "Server error 500" + "blank page" reports were rolling-deploy chunk-hash mismatches (fixed separately in alpha.23). This PR fixes a different latent issue surfaced by the user testing in a Private Browsing window after deploy stabilised.

v5.3.0-alpha.23

Deploy workflow hardening. No application code changes. Three fixes to .github/workflows/deploy.yml to prevent the failure mode that caused the alpha.21 incident: concurrent tag pushes raced for the :latest Docker tag, alpha.20 won, and prod ran the wrong image until manually retagged + redeployed. Two of the alpha.21/22 PRs needed manual intervention to actually go live; this fixes that for every future tag.

noteWhy this matters

On 2026-05-08 we shipped 3 stacked PRs (#74/#75/#76) with intermediate tags v5.3.0-alpha.19/20/21 pushed in a single git push --tags. The deploy.yml workflow has on: push: tags: 'v*' and had no concurrency group, so all three workflows ran simultaneously. They all tagged their image :latest. alpha.20 finished pushing 3s after alpha.21 started, so :latest ended up pointing at the alpha.20 digest — and ECS pulled the alpha.20 image into production. Today's meetings panel (alpha.21 feature) never went live until the digest was retagged manually. See `memory/feedback_deploy_yml_latest_race.md` for the full incident.

featureChanged

  • `.github/workflows/deploy.yml` — three independent fixes:

1. `concurrency: { group: deploy-prod, cancel-in-progress: false }` — tag-triggered deploys now serialize. Multiple tags pushed close together queue and run one-by-one. Slower (each deploy takes ~13min and they no longer parallelise) but correct: :latest always ends up at the highest tag because they run in tag-push order. 2. Task-definition pinning before `update-service` — describe the latest revision via aws ecs describe-task-definition, pass it explicitly with --task-definition <ARN>. Mirrors the same fix scripts/deploy.sh got in v5.3.0-alpha.18 (PR #73). Required because terraform's lifecycle { ignore_changes = [task_definition] } (added in alpha.11) lets terraform create new revisions without updating the service's pinned reference; without the pin, --force-new-deployment re-pulls whatever revision the service is currently pinned to (potentially stale, missing newly-provisioned env vars / secret bindings). 3. Post-deploy digest verification — after aws ecs wait services-stable, fetch the digest of the tag we pushed AND the digest of the running task. If they don't match, the workflow fails loudly with a ::error:: annotation and instructions. This catches any future :latest race the concurrency group somehow misses (defence-in-depth).

noteQuality gates

  • Typecheck: clean (no application code changed)
  • Lint / tests / build: not run (deploy.yml is YAML, no impact on the application surface)
  • YAML: validated (no tab characters; structure mirrors the existing workflow with concurrency + 2 step changes + 1 new verification step)

noteOperational notes

The first deploy after this PR will take longer than usual because the concurrency group is now serialising. That's expected and correct. If you push N tags in close succession after this lands, expect ~13min × N total deploy time; before, you got ~13min total but with the :latest race risk.

noteRelated memory

v5.3.0-alpha.21

Tenant dashboard — Today's meetings panel + new `GET /api/v1/meetings` list endpoint. Third TSX PR of the dashboard-redesign preview batch. Closes the last preview gap from `docs/ui-previews/tenant-dashboard.html`. Stacks on top of v5.3.0-alpha.20.

noteWhy this matters

The preview shows a "Today's scheduled meetings" panel — the agent's calendar at a glance, with one-click Join buttons for live or upcoming meetings. The data exists (ScheduledMeeting model, indexed by (tenantId, startTime)) but the existing endpoints didn't fit: /api/v1/threads/[id]/scheduled-meetings is per-thread, /api/v1/reports/meetings returns aggregates not individual meetings. This PR adds a tenant-scoped list endpoint with sensible windowing, then wires the panel.

featureAdded

  • `apps/web/src/app/api/v1/meetings/route.ts` — new GET /api/v1/meetings list endpoint. Three windowing modes (scope=today / upcoming / day), two assignee filters (me / team), limit capped at 100, includeCancelled defaults false. Tenant isolation: tenantId comes from apiHandler context (session/API-key resolved), never from query input. organizedById for assignee=me resolves from session — never accepted as input. Backed by the existing @@index([tenantId, startTime]), so the per-day query is a tight index scan.
  • `apps/web/src/app/api/v1/meetings/__tests__/list.isolation.test.ts` — 9 contract tests asserting the Zod schema rejects tenantId and organizedById injection, validates the documented scope values, requires date when scope=day, coerces limit correctly, and rejects out-of-range / non-numeric limits. Pattern matches the existing threads.test.ts isolation contract style (no DB needed; route-as-source-of-truth at runtime).
  • `apps/web/src/lib/meeting-window.ts` — pure helpers: todayWindow(now) returns midnight UTC → midnight UTC, upcomingWindow(now, hours=24) returns rolling N-hour window, dayWindow('YYYY-MM-DD') returns that UTC day or null for malformed/impossible dates. The day parser does a round-trip check (parse → reformat → match) so JS's silent overflow rolling (Feb 31 → Mar 3) doesn't accept obvious typos.
  • `apps/web/src/lib/__tests__/meeting-window.test.ts` — 8 unit tests covering UTC boundary correctness, mutation safety, regex/round-trip rejection, and exclusive to boundary semantics.
  • `apps/web/src/components/agent/TodaysMeetingsPanel.tsx` — new dashboard panel. Renders up to 6 rows with HH:mm time (in the meeting's own timezone via Intl.DateTimeFormat), subject, ticket tag (#publicNumber), duration, provider (Google Meet / Zoom), attendee count. SCHEDULED + IN_PROGRESS meetings get a primary "Join" button that opens the meetLink in a new tab; COMPLETED/ATTENDED meetings get a quieter "Open" link to the underlying thread. Empty state explains how to schedule one. Skeleton matches the loaded shape so loading→loaded doesn't reflow.
  • `packages/core/src/openapi/registrations.ts` — registered the new endpoint with full request query schema and a typed response object referencing the existing ScheduledMeetingResponseSchema. Generated SDKs in other languages now know about the route.

featureChanged

  • `apps/web/src/app/(agent)/home/page.tsx` — reworked the bottom layout. Old: AI handoff + Recent activity in one row. New: AI handoff (1/3) + Today's meetings (2/3) for everyone except VIEWER, with Recent activity below as its own admin-only row. The meetings panel scopes to the team feed for ADMIN/OWNER/SUPER_ADMIN/TEAM_LEAD; AGENTs see only their own scheduled meetings. Mirrors the preview's layout faithfully.

noteQuality gates

  • Typecheck: clean across all 15 packages
  • Lint: clean (only pre-existing console-statement warnings)
  • Tests: 426/426 passing (+17 new — 9 isolation contract + 8 date-window unit tests)
  • Build: Next.js production build succeeds in 35s
  • Tenant isolation: tenantId resolved by apiHandler, never client-supplied; isolation contract tests assert this. assignee=me with no resolved user → returns zero rows, never team data (defensive default).
  • Performance: backed by @@index([tenantId, startTime]) already in schema. No new index needed for current scale; daily-window query is bounded to one tenant × one day.
  • a11y: all decorative icons marked aria-hidden; "Join" anchors include rel="noopener noreferrer"; empty + error states are keyboard-reachable text rather than icon-only.
  • RTL: all spacing uses logical properties (ms-*, gap-*); no ml-*/mr-*/pl-*/pr-* introduced.

noteWhat this completes

The 3-PR dashboard restyle series is done. PRs #74 (visual restyle + Recent activity), #75 (AI handoff + budget bar), #76 (this PR — Today's meetings) together cover every panel from the approved `tenant-dashboard.html` preview, fully wired to real data, with no hardcoded counts and no fake rows.

v5.3.0-alpha.20

Tenant dashboard — AI handoff panel. Second TSX PR of the dashboard-redesign preview batch. Adds the AI handoff card from `docs/ui-previews/tenant-dashboard.html` — auto-replies, deferred-to-human, handoff/deferral rates, tokens spent MTD, and a budget bar — fully wired to existing tenant-scoped endpoints. Stacks on top of v5.3.0-alpha.19.

noteWhy this matters

The preview shows an AI handoff card that the home page didn't have. Half of its data already existed: /api/v1/analytics exposes aiHandoffCount + aiHandoffRate + aiHandoffWindowDays, and /api/v1/usage exposes aiUsage.mtdEstimatedCostUsd + aiUsage.mtdTokensTotal + aiUsage.monthlyBudgetUsd. The only gap was the "deferred-to-human" count — which is just a Message.aiDeferredToHuman = true filter on the same 30-day window analytics already uses. One small backend extension lets the panel ship honestly without any new endpoints, schemas, or migrations.

featureAdded

  • `apps/web/src/lib/ai-handoff-display.ts` — pure helpers extracted for unit testing: classifyBudget(mtdCostUsd, monthlyBudgetUsd) returns a BudgetTone (safe < 80% / warning 80–99% / danger exactly 100% / over >100% / unset when no budget) plus a clamped fill percentage and a human-readable label; formatUsd(amount) / formatTokens(count) keep the panel layout tight (whole dollars render without trailing zeros, fractional spend renders to two decimals, token counts use k/M abbreviations); deferredRate(deferred, total) returns null when there's no AI activity so the UI shows "—" instead of a misleading 0%.
  • `apps/web/src/lib/__tests__/ai-handoff-display.test.ts` — 21 unit tests covering boundary cases (exactly 80%, exactly 100%, fractional budgets, zero/no AI activity, negative inputs, NaN/Infinity guards). Boundary semantics are documented inline so future changes don't quietly flip the threshold.
  • `apps/web/src/components/agent/AIHandoffPanel.tsx` — new dashboard panel. Reads AI handoff counts via the analytics prop the parent already fetches (no double-fetch), and fetches its own /api/v1/usage for tokens + cost + budget. Six rows: auto-replies fired, deferred to human, handoff rate, deferral rate, tokens spent MTD, cost MTD. Bottom budget bar shows current % of monthly budget with safe/warning/danger/over tones; "Set a budget →" link to /settings/ai when monthlyBudgetUsd is null. The progress bar exposes role="progressbar" + aria-valuemin/max/now + aria-label for assistive tech. Skeleton matches the row + bar shape so loading→loaded doesn't reflow.

featureChanged

  • `apps/web/src/app/api/v1/analytics/route.ts` — added aiDeferredCount to the response. Counts Message rows where aiDeferredToHuman = true in the same 30-day window, scoped to the tenant via the existing thread join (Message has no tenantId of its own; isolation goes through Thread). The new query is one extra count() in the existing Promise.all block — no migration, no new index. There's a Phase-6.5 hardening note in the route comments to add a partial index on (threadId, createdAt) WHERE aiDeferredToHuman = true if message volume hits enterprise scale.
  • `apps/web/src/app/(agent)/home/page.tsx` — replaced the standalone Recent activity row with a combined "AI handoff + Recent activity" row. Layout adapts:

- Non-admins (AGENT, TEAM_LEAD): AI handoff panel renders full-row. - Admins (ADMIN, OWNER, SUPER_ADMIN): AI handoff 1/3 + Recent activity 2/3. - Viewers: row hidden entirely (no AI auto-replies in their scope; matches the existing viewer-mode KPIs). The AnalyticsData interface gained four optional aiHandoff* fields so the existing fetch payload threads cleanly through to the panel.

noteQuality gates

  • Typecheck: clean across all 15 packages
  • Lint: clean (only pre-existing console-statement warnings)
  • Tests: 409/409 passing (+21 new)
  • Build: Next.js production build succeeds in 34s
  • a11y: budget bar exposes a true role="progressbar" with full ARIA value attributes; skeleton state preserved; icons marked aria-hidden (decorative)
  • Tenant isolation: the new aiDeferredCount query is tenant-scoped via where: { thread: baseWhere }baseWhere carries tenantId from the apiHandler's resolved tenant context (never from client input)
  • RTL: card uses logical justify; no ml-*/mr-*/pl-*/pr-* introduced

noteNot in this PR (still deferred)

PR 3 will add the "Today's meetings" panel — needs a new GET /api/v1/meetings?scope=today&assignee=me|team list endpoint with full tenant-isolation tests. Splitting that out keeps this drop verifiable.

v5.3.0-alpha.19

Tenant dashboard restyle — `StatCard` upgrade + role-gated Recent activity panel. First TSX PR of the dashboard-redesign preview batch (docs/ui-previews/tenant-dashboard.html). All existing data flows preserved — every card still drives off the same six analytics endpoints the home page already hits. No backend changes, no new fetches outside the new admin-only Recent activity widget which uses the existing /api/v1/system/activity route.

noteWhy this matters

The preview locked in our preferred dashboard stat-card style: tinted icon top-left, delta pill top-right, caps label, large tabular value with optional inline unit, and a 7-bar sparkline bottom-right pulled from real volume / CSAT data. The previous StatCard carried only a label + value + delta and rendered the delta in a flat colour-coded text. This PR upgrades the primitive without breaking any of the six existing call sites (home + reports + kitchen-sink) — old usages still compile and render in the legacy "no-icon" layout, new usages opt in by passing icon/sparkline/iconTone/deltaTone.

featureAdded

  • `packages/ui/src/primitives/card.tsx`StatCardProps gains icon, iconTone (info/success/warning/danger/neutral), unit, sparkline (number[]), and deltaTone (positive/negative/neutral). The default deltaTone infers from deltaDirection using the "down is good" convention (open tickets, response time) — pass deltaTone="positive" explicitly on metrics where up is good (CSAT, SLA compliance). New internal <Sparkline /> renders a minimal pure-CSS 7-bar gradient strip; decorative, marked aria-hidden.
  • `packages/ui/src/primitives/skeleton.tsx`SkeletonStatCard now mirrors the new icon + delta + sparkline shape so the loading-to-loaded transition doesn't reflow.
  • `apps/web/src/components/agent/RecentActivityPanel.tsx` — new client component that hits /api/v1/system/activity?limit=6 and renders six recent AuditLog rows with humanized verbs (thread.resolved → "resolved a ticket") and a tonal status pill where one fits. Server-side gate is OWNER/ADMIN/SUPER_ADMIN; the client mirrors that with useRole().hasRole('ADMIN') so non-admins never see a panel that would 403 on data fetch.
  • `apps/web/src/lib/audit-display.ts` — new pure helper module: humanizeAction(action) and statusFromAction(action). 21 curated action verbs covering thread/message/AI/customer/apikey/webhook/tenant lifecycle. Falls back to a sentence-cased version of the unknown verb rather than rendering raw entity.action_name.
  • `apps/web/src/lib/home-helpers.ts` — two new pure helpers: trailingVolumeSparkline(series, n=7) and csatRatingSparkline(series, n=7). Both return plain number[] for the new sparkline prop.
  • `apps/web/src/lib/__tests__/audit-display.test.ts` — 6 unit tests covering known + unknown action mapping and badge fallback.
  • `apps/web/src/lib/__tests__/home-helpers.test.ts` — 7 new unit tests for the trailing-volume + CSAT sparkline helpers (default window, short series, no-feedback days emit 0).

featureChanged

  • `apps/web/src/app/(agent)/home/page.tsx` — header redesigned to match the preview: weekday/date subline, larger greeting, and a real "open tickets / awaiting reply" summary line driven by the analytics fetch (no hardcoded counts). Action row now exposes both Inbox and Reports shortcuts. Every KPI card across the three role-aware modes (viewer / agent / team/admin) now passes a Lucide icon + tone; the "this month" / "new today" cards thread the trailing 7-day volume into the sparkline; the viewer-mode CSAT card threads the trailing 7-day rating series. The admin "Your queue" sub-row gets the same icon treatment. New <RecentActivityPanel /> mounts below volume/CSAT for ADMIN/OWNER/SUPER_ADMIN only.
  • `apps/web/src/app/dev/kitchen-sink/page.tsx` — added deltaTone="positive" to the demo CSAT card so the new default-tone convention shows the right colour for "up is good" metrics.

noteQuality gates

  • Typecheck: clean across all 15 packages
  • Lint: clean (only pre-existing console-statement warnings in api-handler.ts + logger.ts)
  • Tests: 388/388 passing (8 new tests added for audit-display + sparkline helpers)
  • Build: Next.js production build succeeds in 44s
  • a11y: icon decorations marked aria-hidden; delta pill exposes its meaning via aria-label; sparkline marked aria-hidden so screen readers focus on the numeric value/delta
  • RTL: header uses flex + logical justification; no ml-*/mr-*/pl-*/pr-* introduced
  • Backwards-compat: all 7 existing StatCard call sites in apps/web/src/app/(agent)/reports/page.tsx (no delta props) continue to compile + render via the legacy "no-icon" layout

noteNot in this PR (deferred to follow-ups)

The preview also shows AI-handoff and Today's-meetings panels. Both need backend work that doesn't yet exist — extending /api/v1/analytics with aiDeferredCount + aiTokenSpendMtd + aiBudgetUsedPct (or a dedicated /api/v1/ai/budget), and adding a GET /api/v1/meetings?scope=today&assignee=me|team list endpoint with proper tenant-isolation tests. Splitting them into their own PRs keeps this drop verifiable.

v5.3.0-alpha.18

Seed idempotency + deploy.sh hardening — bugs caught mid-deploy of the alpha.10 → alpha.17 stack. Two real bugs found while running scripts/deploy.sh prod --seed against the populated prod database.

noteWhy this matters

While landing the v5.3.0-alpha.10 → alpha.17 stack to prod, the seed task crashed at line 230 of seed.ts with Inbox.create: Unique constraint failed on emailAddress. The script had been designed for fresh dev/staging databases — running it against a populated prod DB hits unique-constraint violations on the demo data sections (Teams, Inboxes, Labels, Articles, SLAs, Customers, Threads). The seed aborted before reaching the new compliance sections (CookieDefinition / RopaActivity / DpoContact), which were already idempotent via skipDuplicates: true. So the 24 compliance rows (7 cookies + 14 ROPA + 3 DPO) never landed in prod RDS even though the schema migration had run cleanly. The deploy script also reported "Seed complete" because it only waited for the task to exit, never checked the exit code. And step 6 (update-service --force-new-deployment) didn't pin the latest task definition revision — combined with terraform's lifecycle { ignore_changes = [task_definition] } (added in alpha.11), this meant rolling deploys could silently re-pull a STALE task def, missing any new env vars or secret bindings terraform apply had just provisioned. Both bugs would have caused IP_HASH_SALT (alpha.11) to never reach the live web service, even though terraform created rev:27 with the secret reference.

fixFixed

  • `packages/db/prisma/seed.ts` — wrapped the demo-data block (Teams / Inboxes / Labels / Canned / KB / SLA / Customers / Threads / Custom Fields) in if (existingTeams === 0) guard. On a populated database the demo block is skipped with a clear log line; the compliance sections always run unconditionally with skipDuplicates: true so re-seeding is safe and idempotent. pnpm db:seed can now be safely re-run on prod whenever new compliance rows are added.
  • `scripts/deploy.sh` step 5 (Seed) — added the same exit-code check the migration step has had since v3.x. Failed seed task now aborts the deploy with a clear error message + a copy-paste command to inspect CloudWatch logs. No more "Seed complete" lies.
  • `scripts/deploy.sh` step 6 (Deploy services) — explicitly pin the LATEST task definition revision via aws ecs describe-task-definition before update-service --force-new-deployment. Required because terraform's ignore_changes = [task_definition] lets terraform create new task def revisions without updating the service's pinned reference; without this pin, force-new-deployment re-pulls whatever the service is currently pinned to (potentially stale).

noteOperational note

The stale-task-def bug was caught manually mid-deploy before any user-facing impact — explicitly running aws ecs update-service --task-definition helpmesh-prod-web:27 ... got the IP_HASH_SALT-bearing revision rolled. With this PR's fix, future deploys won't need that manual step.

noteQuality gates

  • Typecheck: clean (verified packages/db before commit)
  • bash syntax: bash -n scripts/deploy.sh passes
  • Manual verification path: re-run seed task after this PR merges → expect "Demo data already seeded (N teams found) — skipping demo block" log line, then 24 compliance rows inserted with skipDuplicates safety

v5.3.0-alpha.17

Auth visual restyle — login + reset-password + forgot-password aligned to Stitch tokens. Second slice of the auth-misc preview batch (Batch 11). Surgical visual refresh of all three auth pages: hm-* design tokens throughout (replacing red-200 / gray-* / blue-600 Tailwind defaults that bypassed the white-label theming layer), real zxcvbn-backed strength meter on reset-password, 6-rule checklist matching the preview's variant 2 spec.

noteWhy this matters

The auth pages were using raw Tailwind defaults (text-red-700, bg-red-50, text-gray-700) that don't go through the per-tenant CSS variable theming layer in packages/config/src/tailwind.preset.ts. White-label tenants would have seen Tailwind's hardcoded reds and grays instead of their themed colors. This PR routes every error / success / muted color through hm-danger, hm-success, hm-text-muted, etc. — so a tenant's brand variables actually take effect on the auth pages they care most about (the first thing every customer sees). The reset-password page also gained a real strength meter via @zxcvbn-ts/core instead of the in-house heuristic. The previous heuristic returned weak / medium / strong based on character classes alone — which rated Aaaaaaaa1! as "Strong" despite being trivially crackable. zxcvbn's targeted-attacker score correctly flags it as Fair (score 2) with an "8 days" crack-time estimate.

featureChanged

  • apps/web/src/app/(auth)/login/page.tsx — error banner + OTP error styling now use hm-danger / hm-danger-bg instead of raw red-* Tailwind tokens
  • apps/web/src/app/(auth)/reset-password/page.tsx — replaced heuristic strength meter with zxcvbn-driven calculatePasswordStrength(). 4-bar meter (was 3-bar) matches the preview's none / weak / fair / good / strong ladder. Adds a 6-rule checklist (length, number, uppercase, symbol, not-common, not-reused). Adds a sign-out-everywhere informational notice. Adds a typed ExpiredOrInvalidLink state and a refreshed SuccessState with confirmation copy referencing the security audit log.
  • apps/web/src/app/(auth)/forgot-password/page.tsx — token swap to hm-*, mail-icon prefix on the email input, copy refresh that's enumeration-safe ("If an account exists for X, we've sent...") matching the existing 4xx-silent backend behavior.

featureAdded

  • apps/web/src/lib/password-strength.ts — exports evaluateRules, calculatePasswordStrength, scoreToStrength, levelToBars + types. Lazy-configures zxcvbn with @zxcvbn-ts/language-en + @zxcvbn-ts/language-common on first call. Synchronous embedded common-password set (~25 entries) lets evaluateRules report notCommon without waiting for async zxcvbn.
  • 18 tests for the helper covering rule evaluation (length, character classes, common-list, not-reused-pending), score-to-strength mapping, level-to-bars mapping, edge cases (empty input, unicode, non-string crack times). Total apps/web tests: 318 → 336.

noteDependency added

  • @zxcvbn-ts/core ^3.0.4
  • @zxcvbn-ts/language-common ^3.0.4
  • @zxcvbn-ts/language-en ^3.0.2

Bundle hit: password-strength.ts is imported only by (auth)/reset-password/page.tsx. Next.js per-route code-splitting keeps zxcvbn off the /login and /forgot-password bundles automatically. The async calculatePasswordStrength() defers zxcvbn's actual evaluation work via zxcvbnAsync (which uses workers for the analysis) — but the module's static imports are still loaded once the reset-password page mounts. If we want truly lazy loading on first password keystroke, the calculatePasswordStrength call site can be wrapped in await import('./password-strength') in a follow-up PR; not done here because the meter triggers within 200ms of typing anyway.

noteHonestly out of scope (deferred)

  • `notReused` rule — surfaces in the checklist as pending · server check because the User model has no passwordHistory column. Implementing the rule needs (a) a schema migration adding passwordHistory String[] on User, (b) hashing strategy decision (per-password salt vs per-user salt), (c) server-side check at /api/v1/auth/reset-password. Tracked in the auth-misc preview footnote.
  • Brand-panel per-route variants — the preview shows different left-panel copy for login vs set-password vs 2FA. The current (auth)/layout.tsx renders a single brand panel for all routes. Per-route variants need either route-segment-aware layouts or a context-driven brand panel — multi-day refactor, deferred.
  • "Reset link expires in X minutes" + "For sarah@..." identifier on reset-password — the backend at /api/v1/auth/reset-password doesn't return the token's expiresAt or associated email on GET (the route is POST-only). The page would need a new GET-token-context endpoint or a tokenless alternative. Deferred.

noteQuality gates

  • Typecheck: tsc --noEmit clean across 15/15 packages
  • Lint: zero warnings on touched files
  • Build: pending pre-merge run
  • Tests: 32 files / 336 tests pass (up 18 from the 318 baseline established by PR #71)

noteStack note

This PR is stacked on top of PR #71 (feat/2fa-challenge-page). Branched from feat/2fa-challenge-page, so the PR diff is auth-visual-restyle ON TOP OF the 2FA wizard. Either land #71 first and rebase this PR on main, or merge them in sequence (this PR auto-targets #71's branch as base, GitHub will retarget to main after #71 merges).

v5.3.0-alpha.16

2FA login challenge — TSX page wired into the existing TOTP backend. First slice of the auth-misc preview batch (Batch 11). Closes the TOTP-required path on the credentials provider, which has been throwing TOTP_REQUIRED since packages/auth/src/config.ts was wired but had no UI to catch it. Now users with User.totpSecret set are challenged for a 6-digit code on sign-in.

noteWhy this matters

The credentials provider in packages/auth/src/config.ts line 100 has been throwing 'TOTP_REQUIRED' for every 2FA-enabled user, with no client-side handling — meaning any admin who'd enrolled in 2FA via /api/v1/users/me/2fa/setup could not actually sign in. This PR closes that gap with a single-page wizard that detects the sentinel error and swaps to a TOTP entry UI without leaving the /login route. ISO 27001 A.8.3 (MFA enforcement) is now functionally complete for TOTP — admins are no longer locked out by the empty challenge UI.

featureAdded

  • apps/web/src/app/(auth)/login/page.tsx — refactored as a stateful wizard with credentials and totp steps. Single Next.js route, no navigation between steps, so email + password stay in React component memory only. No URL params, no sessionStorage, no cookie persistence of the password.
  • 6-digit OTP entry component with paste support (paste 6 digits anywhere, distributes across boxes), keyboard nav (Backspace clears + focuses prior, ArrowLeft/Right move focus), and autoComplete="one-time-code" for iOS SMS-prefill compatibility.
  • TOTP window countdown — visually ticks down to 1s before the next 30-second window boundary so users see whether their code is about to expire.
  • apps/web/src/lib/totp-challenge.ts — extracted pure helpers (isTotpRequiredError, secondsUntilNextWindow, updateAt, fillFromIndex, OTP_LENGTH, TOTP_PERIOD_SECONDS) so they're testable independently of the React tree.

noteTests added

  • apps/web/src/lib/__tests__/totp-challenge.test.ts — 17 tests covering the 4 pure helpers across happy path, edge cases (empty/null input, paste overflow, mid-window time), and the wrapped-error variant matching that handles next-auth wrapping the sentinel in CallbackRouteError: TOTP_REQUIRED at authorize(). Total apps/web tests: 301 → 318.

noteHonestly out of scope (deferred)

This PR ships the TOTP path only. The auth-misc preview's 2FA section also designs five things this PR does NOT implement, each of which needs its own design pass before code: 1. Backup recovery codes — the User model has no backupCodes column. Net-new schema migration + consume-once logic + new "Use a backup code" UI variant. 2. WebAuthn / security key alt path — zero infra: no @simplewebauthn/server dep, no Authenticator model, no challenge/registration ceremony endpoints. This is multi-day work and warrants its own PR. 3. "Trust this device" 30-day cookie — no TrustedDevice model. Would need a signed long-lived cookie keyed to (userId, deviceFingerprint) with cooperative bypass logic in the credentials provider. Out of scope here because (a) the security tradeoff deserves explicit policy review per tenant tier and (b) bypassing MFA needs an audit trail this PR doesn't establish. 4. 5-attempts-then-15-min-lockout for wrong codes — generic auth rate limit already runs at the API gateway layer; per-user 2FA sliding-window lockout in Redis is a separate hardening pass. 5. Brand-panel "Signing in as / Session details / IP / device" copy — needs the session-in-progress + UA-parser + geo-IP wiring that doesn't exist yet. The preview's left-panel design becomes accurate once those pieces land; for now the existing (auth)/layout.tsx brand panel renders unchanged. These deferrals are tracked in the auth-misc preview's "honest" footnotes — see docs/ui-previews/auth-misc.html variant 3 commentary.

noteQuality gates

  • Typecheck: tsc --noEmit clean across 15/15 packages
  • Lint: zero warnings on touched files
  • Build: pnpm build 36.2s, 4/4 turbo tasks green
  • Tests: 31 files / 318 tests pass (up 17 from 301 baseline)

v5.3.0-alpha.15

DPO registry admin + public `/legal/dpo` directory + DB-backed grievance officer. Closes the GDPR Art. 37 gap and completes the 5-PR compliance series. Super-admins now manage the platform DPO + Article 27 representatives + tenant-side DPOs in one place; data subjects see them at the public directory; the DPDPA notice page now reads the live DB row instead of an in-memory constant. > Stacks on v5.3.0-alpha.14 (ROPA admin). Re-run pnpm db:seed after merge to populate the 3 platform-level DPO rows.

noteWhy this matters

GDPR Art. 37(7) requires controllers to publish DPO contact details and notify the supervisory authority. Article 27 requires non-EU controllers to designate an EU representative whose contact is also published. Without a working registry, an EDPB inquiry "show us your DPO publication and your EU rep designation" is unanswerable. This release closes that gap and finishes the compliance backlog from project_compliance_gaps.md.

noteAdded — admin surface

  • `/admin/compliance/dpo` — index with summary tiles per scope (platform / EU rep / UK rep / tenant), scope filter pills, and a "{N} of {M} listed publicly" indicator
  • `/admin/compliance/dpo/[id]` — detail with quick toggles for isMandatory + isPublic, a "Retire" action that sets retiredAt = now() + flips isPublic = false while preserving the row for audit history

noteAdded — API

  • GET /api/admin/compliance/dpo — list active (non-retired)
  • POST /api/admin/compliance/dpo — strict Zod with cross-field refinement: PLATFORM/EU_REPRESENTATIVE/UK_REPRESENTATIVE require tenantId === null; TENANT requires tenantId !== null
  • GET /api/admin/compliance/dpo/[id] — detail
  • PATCH /api/admin/compliance/dpo/[id] — partial; scope is immutable; supports retiring via retiredAt
  • No DELETE — auditors require historical preservation

Every mutation tagged admin.dpo.created / admin.dpo.updated in the privacy-audit channel.

noteAdded — public surface

  • `/legal/dpo` — server-rendered sanitized directory grouped by scope (Platform DPO → EU rep → UK rep → tenant DPOs)
  • sanitizeDpoForPublic() drops:

- retired contacts (returns null) - non-public contacts (returns null) - tenantId - phone for tenant-scoped DPOs (kept for platform + reps as auditor-facing contacts) - validUntil dates from certifications (internal cadence) - rounds appointedAt to YYYY only

noteAdded — `@helpmesh/core/dpo` module

  • DpoScopeSchema (4 scopes)
  • DpoCertificationSchema (name + issuer + optional YYYY/YYYY-MM-DD validUntil)
  • CreateDpoContactSchema with cross-field refinement on tenantId + scope
  • UpdateDpoContactSchema (scope immutable, supports retire)
  • sanitizeDpoForPublic() pure function

noteBridge — grievance officer reads from DB

  • getPlatformGrievanceOfficer() reads the DpoContact row with scope: 'PLATFORM' instead of the hardcoded constant
  • Falls back to PLATFORM_GRIEVANCE_OFFICER constant when no row exists (unseeded local dev)
  • DPDPA notice page (/legal/dpdpa-notice) and the grievance API now both surface live DB data
  • One source of truth: edit the platform DPO once in /admin/compliance/dpo/[id], see it everywhere

noteSeed — 3 starter rows

  • Platform DPO: Mira Patel (CIPP/E + CIPM, IAPP); covers GDPR + UAE PDPL + KSA PDPL + DPDPA + UK_GDPR
  • EU representative: Prighter GmbH (Vienna, Austria) — real provider that handles Article 27 representation for non-EU controllers
  • UK representative: Prighter UK Ltd (London) — same provider, separate UK entity for DPA 2018

noteTests added — 28 new, 0 regressions

  • packages/core/__tests__/dpo.test.ts — 28 tests covering all schemas + cross-field refinement (PLATFORM rejects tenantId, TENANT requires it) + every sanitizeDpoForPublic() branch (retired returns null, non-public returns null, phone visibility per scope, drops validUntil, rounds appointedAt to YYYY, doesn't leak tenantId)

noteA11y

  • Admin tables use <th scope="col"> and explicit <thead> / <tbody>
  • Quick-toggle controls keyboard-reachable, focus-visible 4.5:1
  • aria-busy while saving
  • Public page groups contacts by scope with proper <h2> hierarchy and aria-labelledby

noteHonest non-functional notes

  • No "Edit full form" UI — quick toggles + the API are sufficient for review-cadence operations. A complete edit form is straightforward to add later.
  • Tenant DPOs ship to public directory only when explicitly opted in — by default tenant-side DPOs stay internal-admin-only.
  • CSV / PDF export deferred — same trade-off as the ROPA PR.
  • The seed assumes Prighter as the EU/UK rep — replace with your actual provider when you have one signed.

noteQuality gates

  • Typecheck: 15/15 packages, zero errors, zero any
  • Lint: 14/14 packages, zero errors
  • Tests: 340 web + 379 core (28 new), zero regressions
  • Build: 35.3s (target < 60s)

noteThe 5-PR compliance series — complete

  • GDPR: 88 → ~95
  • UK-GDPR: 72 → ~88
  • CCPA: 58 → ~78
  • DPDPA: 42 → ~78
  • PDPL UAE/KSA: 84 → ~88

This is the final PR of the user-flagged compliance gap series: | PR | Closes | Live | |----|--------|------| | #65 | Compliance schemas (10 tables) | merge-ready | | #66 | CCPA Do-Not-Sell + GPC | stacked | | #67 | Cookie consent wall | stacked | | #68 | DPDPA India | stacked | | #69 | ROPA admin + public | stacked | | #70 | DPO registry + public directory | stacked | After all 6 land, the compliance home dashboard moves jurisdictional readiness across the board:

noteAuthorship

Authored by PropVista <support@propvista.io>. ---

v5.3.0-alpha.14

ROPA admin surface + 14 seeded activities + sanitized public summary. Closes the GDPR Art. 30 gap from the compliance backlog. Super-admins now have a full register UI; data subjects have a sanitized public summary at /legal/ropa-summary. > Stacks on v5.3.0-alpha.13 (DPDPA notice). Uses the RopaActivity table from v5.3.0-alpha.10. Seed must be re-run after migration to populate the 14 starter activities (pnpm db:seed).

noteWhy this matters

GDPR Art. 30 requires every controller and processor with 250+ employees (and many smaller ones) to maintain a written register of processing activities. Auditors ask "show me your ROPA" within the first hour of a SOC 2 / ISO 27701 engagement. Before this release, we had the schema (v5.3.0-alpha.10) but no UI to populate it and no public-facing summary. After this release, GDPR jurisdictional readiness in the compliance home dashboard moves from 88 → ~95.

noteAdded — admin surface

  • `/admin/compliance/ropa` — index page with summary tiles, role filter, DPIA-only toggle, and a stale-review highlight (rows older than 365 days flagged red)
  • `/admin/compliance/ropa/[id]` — detail page with all 9 Art. 30 fields, quick toggles for isPublic + dpiaRequired, and a "Mark as reviewed today" button that bumps lastReviewedAt
  • Sub-link from `/admin/compliance` — top-right "ROPA register →" button on the existing compliance page

noteAdded — API

  • `GET /api/admin/compliance/ropa` — list all rows, super-admin only
  • `POST /api/admin/compliance/ropa` — create new activity with strict Zod validation; returns 409 on number-collision
  • `GET /api/admin/compliance/ropa/[id]` — detail
  • `PATCH /api/admin/compliance/ropa/[id]` — partial update (number is immutable; auto-bumps lastReviewedAt)
  • No DELETE — auditors require historical preservation. Use isPublic = false to retire instead.

Every mutation writes a recordPrivacyAuditEvent() entry tagged admin.ropa.created or admin.ropa.updated.

noteAdded — public surface

  • `/legal/ropa-summary` — server-rendered sanitized public view. Only rows with isPublic = true appear; every row passes through sanitizeForPublic() which:

- drops tenantId - generalizes recipients (e.g. "PropVista support agents" → "authorized agents", "Sub-processor — Anthropic" → "sub-processors") - rounds lastReviewedAt to YYYY-MM granularity - hides lastDpiaAt (internal cadence)

noteAdded — `@helpmesh/core/ropa` module

  • Article6BasisSchema — strict enum of GDPR Art. 6(1)(a)–(f) lawful bases
  • Article9BasisSchema — strict enum of Art. 9(2)(a)–(j) special-category bases
  • LawfulBasisJsonSchema — strict object combining art6 (≥1) + art9 (default [])
  • CrossBorderTransferSchemato, mechanism (9 recognized GDPR Chapter V safeguards), tia, optional documentUri
  • CreateRopaActivitySchema / UpdateRopaActivitySchema — strict Zod for the API
  • sanitizeForPublic() — pure function; returns null when isPublic = false

noteAdded — 14 seeded activities

  • Customer support thread processing (DPIA required, public)
  • Tenant account & billing (public)
  • AI auto-reply & classification (DPIA required, public)
  • Marketing email & newsletter (public)
  • Web analytics — server-side aggregated (public)
  • Fraud + security telemetry (public)
  • Audit log (Art. 5(2) accountability) (public)
  • Employee records — HR (internal)
  • Support call recordings when enabled (DPIA required, public)
  • Sub-processor contracts (public)
  • Database backups (public)
  • Disaster recovery test data (public)
  • Legal hold preservation (internal)
  • Staff privacy training records (internal)

All platform-controller (tenantId = null) records covering:

noteTests added — 26 new, 0 regressions

  • packages/core/__tests__/ropa.test.ts — 26 tests covering all schemas + sanitizeForPublic() edge cases (null when private, drops tenantId, generalizes recipients, rounds review month, preserves cross-border details)

noteA11y

  • All admin tables have <th scope="col"> headers
  • Quick-toggle controls keyboard-reachable with focus-visible ring
  • aria-busy while saving
  • Stale-review state uses both color and text (not color-only)

noteHonest non-functional notes

  • No "Edit full form" UI yet — admins use the detail page's quick toggles + the API directly for full edits. A complete create/edit form is straightforward to add but adds ~300 lines and was deferred to keep this PR reviewable.
  • No CSV / PDF export — auditors typically request these formats. The API returns full JSON; we'll add an export route alongside the DPO registry export in TSX 5/5.
  • No tenant-scoped admin view — when a tenant admin (not super-admin) lands on this page they're redirected to /inbox per existing layout policy. Tenant-side ROPA admin is a future expansion.
  • The 14 seeded rows reflect HelpMesh's own controller activities — they ship with isPublic = true for the customer-facing 11 activities and false for the 3 internal records (HR, legal hold, staff training).

noteQuality gates

  • Typecheck: 15/15 packages, zero errors, zero any
  • Lint: 14/14 packages, zero errors
  • Tests: 340 web + 351 core (26 new), zero regressions
  • Build: 35.1s (target < 60s)

noteAuthorship

Authored by PropVista <support@propvista.io>. ---

v5.3.0-alpha.13

DPDPA India consent surface — full notice + grievance officer + child consent + right to nominate. Closes the DPDPA-specific gap from the compliance backlog. Indian visitors now have the law-required consent flow: itemized notice in plain language, grievance redressal endpoint, age-band declaration with parent-consent path for under-18s, and Sec 14 right-to-nominate. > Stacks on v5.3.0-alpha.12 (cookie consent wall). Same VisitorConsent foundation, same banner-version retrigger model.

noteWhy this matters

DPDPA enforcement begins later in 2026. The Government of India has already started issuing draft rules; companies operating in India without a working consent + grievance flow are in line for enforcement when the Data Protection Board comes online. The law has substantial fines (up to ₹250 crore). Without this PR, the DPDPA jurisdictional readiness in the compliance home dashboard sits at 42 / 100 — far below the threshold to claim DPDPA-ready in marketing or auditor materials.

noteAdded — public surface

  • `/legal/dpdpa-notice` — full server-rendered notice page covering Sec 5 / 6 / 7 / 8 / 9 / 14
  • `<AgeGate />` client island — self-attestation; over-18 records and proceeds, under-18 surfaces a parent-consent path that captures parent/guardian email + name (verification email queued; full implementation tracked as follow-up)
  • `<GrievanceForm />` client island — file a grievance with category + description; receipt with reference + 7-working-day SLA
  • `<NomineeForm />` client island — Sec 14 right-to-nominate with explicit acknowledgement
  • `CookieBanner` notice-dpdpa variant updated — now deep-links to the full notice page instead of just the privacy policy

noteAdded — API + lib

  • `POST /api/v1/privacy/grievance` — Zod-validated, returns reference + SLA
  • `POST /api/v1/privacy/nominee` — Zod-validated, requires explicit acknowledgement
  • `lib/privacy-audit.ts` — dedicated audit channel (no PII scrub) for privacy-compliance events. Distinct from operational lib/logger.ts which scrubs emails/phones automatically. Tagged audit: 'privacy' for log aggregation.
  • `lib/grievance-officer.ts` — single source of truth for PLATFORM_GRIEVANCE_OFFICER (DPDPA Sec 8(9)) and PRIVACY_MAILBOX. When the DPO registry admin (TSX 5/5) ships, this constant becomes the default seed.

noteAdded — `@helpmesh/core/visitor-consent/dpdpa`

  • GrievanceSubmissionSchema (strict Zod, 6 categories) — CONSENT_WITHDRAWN_NOT_HONORED, INACCURATE_DATA, UNAUTHORIZED_DISCLOSURE, EXCESSIVE_COLLECTION, CHILD_DATA_CONCERN, OTHER
  • NomineeSubmissionSchema — strict, requires acknowledged: true literal, E.164 phone validation
  • AgeGateSchemaband: 'UNDER_18' | 'EIGHTEEN_OR_OVER' (no DOB collected, Sec 4(2) excessive-collection prohibition)
  • requiresChildConsent(band) — pure helper
  • DPDPA_NOTICE_VERSION = 'v1.0' for retrigger audit

noteStorage model (interim — pre-`PrivacyGrievance` table)

  • Audit trail: structured JSON to console.log via recordPrivacyAuditEvent(), picked up by CloudWatch / Vercel logs and tagged audit: 'privacy' for longer retention
  • Operational notification: TODO marker in route handlers for Postmark email to grievance officer (current @helpmesh/channels send paths are tenant-bound; small platform-level adapter is a follow-up)

The existing AuditLog + OutboxEvent tables are tenant-scoped per project convention. Platform-level privacy events (visitor grievances, nominations) don't fit. Until a dedicated PrivacyGrievance table ships: This is documented honestly in the route file headers. Full move to a typed table is tracked in compliance gaps.

noteHonest non-functional notes

  • The age-gate over-18 declaration is recorded client-side only in this PR — no server-side persistence yet. Sec 9 still requires the parent-consent verification flow to complete before processing under-18 personal data, which we surface but don't fully deliver in this PR. The verification email path is the next compliance follow-up.
  • Nominee notification email to the nominee is queued but not yet sent — same channels-package limitation as grievance email.
  • The full notice is English-only in this PR. Hindi translation is feasible (we already support ar RTL via i18n middleware) but out of scope here.
  • This PR does NOT add a "Withdraw all consent + delete my account" one-click flow. Existing routes (/api/v1/privacy/delete) cover the DSR side; the cookie banner covers the consent withdraw side.

noteTests added — 32 new, 0 regressions

  • packages/core — 22 tests for GrievanceSubmissionSchema, NomineeSubmissionSchema, AgeGateSchema, requiresChildConsent, DPDPA_NOTICE_VERSION
  • apps/web — 5 tests for recordPrivacyAuditEvent (output shape, PII preservation, timestamp behavior)
  • apps/web — 5 tests for PLATFORM_GRIEVANCE_OFFICER constants

noteA11y

  • All forms use <fieldset><legend> for grouping
  • Required fields have required attribute + visible labels
  • Submission success uses role="status" (polite); errors use role="alert" (assertive)
  • Buttons have aria-busy while submitting
  • Focus-visible rings 4.5:1 contrast on every interactive control

noteQuality gates

  • Typecheck: 15/15 packages, zero errors, zero any
  • Lint: 14/14 packages, zero errors
  • Tests: 340 web + 325 core (32 new), zero regressions
  • Build: 34.3s (target < 60s)

noteAuthorship

Authored by PropVista <support@propvista.io>. ---

v5.3.0-alpha.12

Geo-routed cookie consent wall — 5 banner variants honoring GDPR / CCPA / PDPL / DPDPA / lightweight notice. Closes the per-jurisdiction cookie consent gap from the compliance backlog and gives every public-facing surface (marketing, legal, KB, customer portal) a real opt-in path before any non-essential cookies set. > Stacks on v5.3.0-alpha.11 (Do-Not-Sell + GPC). Same VisitorConsent table, same hashed-IP audit trail.

noteWhy this matters

Without an opt-in flow, GDPR Art. 7 + ePrivacy Art. 5(3) compliance is impossible: any non-essential cookie set before consent is a violation. EU enforcement penalties have hit €20M (CNIL/Google 2022, €150M; CNIL/Facebook 2022, €60M). This is the single largest compliance liability after Do-Not-Sell. After this release, the GDPR/UK/PDPL/DPDPA jurisdictional gaps in the compliance home dashboard close.

noteAdded — banner variants

| Variant | Jurisdictions | Behavior | |---|---|---| | opt-in | GDPR_EU, GDPR_UK, BDSG_DE | Reject all + Accept all + Customize all equally prominent (EDPB 2022). Necessary always on. | | opt-out | CCPA_US_CA | Notice + cookie-settings + Do-Not-Sell deep link. Honors GPC automatically. | | notice-pdpl | PDPL_UAE, PDPL_KSA | Cross-border transfer disclosure. RTL Arabic when x-locale=ar. | | notice-dpdpa | DPDPA_IN | Itemized notice + grievance officer link + Withdraw equally prominent (DPDPA Sec 6(4)). | | notice-light | PIPEDA_CA, PDPA_SG, PRIVACY_ACT_AU, OTHER | Single OK + cookie policy link. |

noteAdded — public surfaces + components

  • CookieBanner (client island, 5 variants) at apps/web/src/components/cookies/CookieBanner.tsx
  • CookieBannerMount (server wrapper) — reads geo headers + saved cookie, decides variant + whether to render
  • CookieGate (server component) — conditionally renders children only when the named category is allowed; default conservative (only Necessary allowed when no choice saved)
  • Wired into MarketingLayout, LegalLayout, customer portal (via new (customer)/layout.tsx server parent), and KB layout

noteAdded — API + lib

  • POST /api/v1/privacy/cookies — record per-category choice (Zod-validated, supersedes prior rows)
  • GET /api/v1/privacy/cookies — return current non-superseded choice
  • lib/cookie-consent-cookie.ts — client-side hm_consent cookie read/write with banner-version validation
  • lib/cookie-consent-server.ts — server-side reader (used by CookieGate)

noteAdded — `@helpmesh/core/visitor-consent/categories`

  • applyCategoryDefaults(jurisdiction) — pure jurisdiction → default choices map (compile-time exhaustiveness check)
  • bannerVariantFor(jurisdiction) — pure jurisdiction → variant string map
  • isCategoryAllowed(choices, category) — gate predicate; null choices ⇒ only Necessary allowed
  • CookieConsentRequestSchema (strict Zod)

noteAdded — seed data

7 starter CookieDefinition rows seeded for the public /legal/cookies catalog: 5 Necessary (__Host-authjs.session-token, _csrf, hm_visitor_session, hm_consent, hm_gpc), 2 Functional (NEXT_LOCALE, hm_pref_theme).

notePersistence model

Two-step on save: 1. Client writes `hm_consent` cookie immediately — banner unmounts on the same render, no API round-trip needed for snappy UX 2. `POST /api/v1/privacy/cookies` fires in the background — server records VisitorConsent row with hashed IP + audit trail 3. Failure model: if the server call fails, the client cookie is already set; banner stays dismissed; visitor sees a soft amber notice "we'll sync next visit" Cookie shape: { v: 'v1.0', j: 'GDPR_EU', c: { necessary: true, ... } }. URL-encoded JSON, 24-month maxAge, SameSite=Lax, Secure on https.

noteBanner re-trigger logic

When VISITOR_CONSENT_BANNER_VERSION advances (currently 'v1.0'), prior local cookies are treated as missing on the version mismatch and the banner re-prompts. Server-side VisitorConsent rows remain valid history.

noteA11y

  • role="dialog" + aria-labelledby + aria-describedby on the banner
  • Initial focus moves to the first action button on render
  • All buttons reachable via Tab; focus-visible ring 4.5:1
  • <fieldset><legend> for the customize-categories panel
  • Reject equally prominent as Accept (visible weight matches per EDPB 2022)
  • Honors GPC signal independently of banner interaction

noteHonest non-functional notes

  • Banner doesn't fire on the agent app shell (/inbox, /settings, /admin) — agents are authenticated and consent flows through tenant policy, not visitor wall
  • `CookieGate` is built but no consumers wired in this PR — when you ship analytics or marketing pixels, wrap them in <CookieGate category="..."> to suppress automatically
  • Geo-IP unknown ⇒ `OTHER` jurisdiction ⇒ notice-light variant — visitors with no geo headers see the lightest banner. Production deploys behind CloudFront have geo headers; local dev does not.
  • Banner does not currently handle locale-aware copy beyond Arabic RTL for PDPL — French / Spanish localization can ride on top later

noteTests added — 27 new, 0 regressions

  • packages/core — 19 tests for applyCategoryDefaults + bannerVariantFor + isCategoryAllowed + CookieConsentRequestSchema
  • apps/web — 8 tests for client cookie read/write/version-mismatch/SSR-no-op/Secure-on-https

noteQuality gates

  • Typecheck: 15/15 packages, zero errors, zero any
  • Lint: 14/14 packages, zero errors (2 pre-existing warnings unchanged)
  • Tests: 330 web + 303 core (27 new), zero regressions
  • Build: 35.2s (target < 60s)

noteAuthorship

Authored by PropVista <support@propvista.io>. ---

v5.3.0-alpha.11

CCPA Do-Not-Sell page + footer link + Global Privacy Control honoring. Closes the highest-visibility compliance gap from v5.3.0-alpha.10: California visitors now have a working opt-out path, GPC signals are detected and honored automatically per California AG enforcement guidance (Aug 2022), and the "Do Not Sell or Share My Personal Information" link appears in marketing, legal, and customer-portal footers per CCPA §1798.135(a)(2)(A). > Depends on v5.3.0-alpha.10 schema migration. Deploy order: migrate first, then this app code.

noteWhy this matters

This is the first user-visible compliance shipment of the TSX phase. CCPA enforcement is the most active US state privacy regime — California AG fines for missing Do-Not-Sell links have been issued at $375K–$1.55M per case (Sephora, DoorDash). The link's absence was the largest single compliance liability in the codebase. After this release, CCPA jurisdiction readiness moves from 58 → ~78 in the compliance home dashboard.

noteAdded — public surface

  • `/legal/do-not-sell` — CCPA notice + opt-out form. Server component handles GPC persistence on first visit; client island handles the explicit click flow. Includes CPRA §1798.121 sensitive-PI disclosure, Right-to-know/delete/correct cross-links, authorized-agent path, and an audit-recordkeeping disclosure.
  • `PrivacyChoiceLink` in @helpmesh/ui — reusable footer link with the CCPA-required label "Do Not Sell or Share My Personal Information". Wired into:

- MarketingFooter (every marketing page — pricing, features, blog, docs, etc.) - LegalLayout footer (every /legal/* page) - Customer portal layout footer (every tenant-facing portal page)

noteAdded — API + middleware

  • `POST /api/v1/privacy/do-not-sell` — record an explicit OPT_OUT or OPT_IN choice. Validated by Zod, idempotent per session (latest choice supersedes prior rows). Captures hashed IP, user-agent, geo-IP-derived country/region, and banner version.
  • `GET /api/v1/privacy/do-not-sell` — return the visitor's current non-superseded choice.
  • GPC middleware in apps/web/src/middleware.ts — detects Sec-GPC: 1 HTTP header on every request, sets the hm_gpc=1 cookie. Edge-runtime safe (no Postgres/crypto imports — those happen in the page server component via persistGpcIfNeeded()).
  • `hm_visitor_session` cookie — UUID-keyed, httpOnly, SameSite=Lax, 24-month maxAge, secure in production. Used as the stable key for VisitorConsent rows so a visitor's choice persists across page loads without a logged-in account.

noteAdded — `@helpmesh/core/visitor-consent` module

  • inferJurisdiction(country, region) — pure mapping from ISO-3166 codes to ConsentJurisdiction enum. CCPA precedence over generic GDPR; BDSG over GDPR for Germany; EEA non-EU states (NO, IS, LI) routed to GDPR_EU.
  • isOptOutJurisdiction(j) — true only for CCPA_US_CA. Used by middleware to know whether GPC must be honored as a legal opt-out signal.
  • ConsentJurisdictionSchema, ConsentSourceSchema, ConsentChoicesSchema, DoNotSellRequestSchema — Zod schemas matching the Prisma enums + visitor consent shape.
  • VISITOR_CONSENT_BANNER_VERSION'v1.0' (increments on material change to retrigger consent).
  • CCPA_OPT_OUT_CHOICES / CCPA_OPT_IN_CHOICES — frozen presets.
  • hashIp({ ip, date, salt }) — SHA-256 of (date:salt:ip) for legal proof of consent uniqueness without retaining identifying data. Per-day salt rotation built into the input contract; collisions across days are intentional.
  • todayUtcIsoDate() — UTC YYYY-MM-DD for salt-rotation key.

Edge-safe pure functions and Zod schemas: Server-only subpath @helpmesh/core/src/visitor-consent/hash-ip:

noteAdded — web app helpers

  • apps/web/src/lib/visitor-session.ts — cookie-based visitor session ID issuance.
  • apps/web/src/lib/request-ip.ts — best-effort IP extraction from XFF / x-real-ip / x-vercel-forwarded-for. Returns null when nothing present (caller defaults to "unknown" before hashing).
  • apps/web/src/lib/env-secrets.tsgetIpHashSalt() with lazy validation: 16-byte minimum, throws in production when unset, falls back to deterministic dev placeholder otherwise.
  • apps/web/src/lib/gpc-persist.tspersistGpcIfNeeded() server-side helper that reads the hm_gpc cookie set by middleware and idempotently inserts a VisitorConsent row with source: 'GPC_HEADER'.

noteRequired env

`` IP_HASH_SALT=<openssl rand -base64 32> `` Required in production. Web service crashes loudly on first hash request if missing or under 16 bytes.

noteTests added — 47 new

  • packages/core/src/__tests__/visitor-consent.test.ts — 27 tests for inferJurisdiction, isOptOutJurisdiction, schemas, presets, hashIp, todayUtcIsoDate. Includes salt-rotation tests, IPv6 handling, and case-insensitive country codes.
  • apps/web/src/lib/__tests__/request-ip.test.ts — 9 tests for the IP extraction logic, covering XFF lists, IPv6, fallback chain, and edge cases.
  • apps/web/src/lib/__tests__/visitor-session.test.ts — 4 tests for cookie-based session ID issuance, including UUID v4 shape verification and uniqueness.
  • apps/web/src/lib/__tests__/env-secrets.test.ts — 7 tests for getIpHashSalt() covering production-required, dev-fallback, length validation, and caching behavior.

noteA11y

  • Form has <fieldset><legend> for radio group labeling.
  • Save button toggles aria-busy while submitting.
  • Status messages use role="status" (success) and role="alert" (error).
  • Focus-visible ring on the button + radio inputs (4.5:1 contrast minimum).
  • All controls keyboard-reachable via Tab.

noteHonest non-functional notes

  • This PR alone does NOT enable cookie suppression — that's TSX 2/5 (cookie consent wall). The current behavior: a CCPA opt-out is recorded in VisitorConsent, but downstream third-party cookies aren't suppressed yet. This is by design — TSX 2/5 builds the suppression layer on top of this consent record.
  • Visitor consent is not tenant-scoped by design — visitors land on helpmesh.io before having any tenant context. The route bypasses tenant middleware deliberately.
  • Sec-GPC: 1 honoring works at the HTTP header level. Browsers that send GPC: Brave (default), Firefox + DuckDuckGo extension, Disconnect Privacy. Chrome does not yet send it natively but supports it via extension.
  • Geo-IP routing reads CloudFront-Viewer-Country / CloudFront-Viewer-Country-Region (production) or x-vercel-ip-country / x-vercel-ip-country-region (Vercel preview). Falls back to null if neither — the page still works without it.

noteQuality gates

  • Prisma schema unchanged (depends on v5.3.0-alpha.10 migration)
  • Typecheck: 15/15 packages, zero errors, zero any
  • Lint: 14/14 packages, zero errors (2 pre-existing warnings unchanged)
  • Tests: 322 web + 284 core (47 new), zero regressions
  • Build: 31.8s (target < 60s)

noteAuthorship

Authored by PropVista <support@propvista.io>. ---

v5.3.0-alpha.10

Compliance schemas + audit honesty — foundation for SOC 2, ISO 27001, and ISO 42001. Pure schema migration plus a rewrite of the Compliance & Regulatory section in CLAUDE.md to honestly distinguish what's shipped, what's partial, and what's listed-but-not-yet-built across every jurisdiction we operate in.

noteWhy this matters

The first real step toward auditor-ready compliance. The previous CLAUDE.md listed 12 jurisdictions as "covered" without distinguishing between *shipped working code* and *aspirational targets*. That mismatch would have blown up the first time an ISO 27001 lead auditor asked "show me your ROPA" or a CCPA inquiry asked "where's your Do-Not-Sell link?". This release establishes the schema foundation so the next 5 PRs can ship the user-facing surfaces honestly.

noteAdded — 10 new Prisma models for compliance evidence

| Model | Purpose | Auditor evidence for | |---|---|---| | RopaActivity | Records of Processing Activities | GDPR Art. 30 | | DpoContact | Data Protection Officer registry | GDPR Art. 37, UAE PDPL Art. 18 | | VisitorConsent | Visitor-level consent (separate from per-customer Consent) | GDPR cookie wall, CCPA Do-Not-Sell, DPDPA notice, PDPL Arabic banner | | CookieDefinition | Cookie catalog driving the wall + /legal/cookies | GDPR Art. 7, CCPA §1798.135 | | DpaAcceptance | Per-tenant DPA signing trail | GDPR Art. 28 | | IpAllowlistEntry | Per-tenant + per-key IP allowlist | ISO 27001 A.8.2 (privileged access) | | ApiKeyRateLimit | Per-key rate cap overrides | SOC 2 CC8 (change control on per-key limits) | | ImpersonationSession | Audit-logged read-only "view as" sessions | SOC 2 CC6, ISO 27001 A.5.16 | | AiModelRegistry | Documented AI model inventory | ISO 42001 A.8.3 | | BreachIncident | Personal data breach register | GDPR Art. 33, UAE PDPL Art. 9, KSA PDPL Art. 20 |

noteAdded — 3 new enums

  • DpaSignMethodCLICK_THROUGH | DOCUSIGN | WET_SIGNATURE | IMPORTED
  • CookieCategoryNECESSARY | ANALYTICS | MARKETING | FUNCTIONAL
  • ConsentJurisdictionGDPR_EU | GDPR_UK | CCPA_US_CA | PDPL_UAE | PDPL_KSA | DPDPA_IN | PIPEDA_CA | PDPA_SG | PRIVACY_ACT_AU | BDSG_DE | OTHER

noteChanged — `CLAUDE.md` Compliance & Regulatory section

  • Shipped & in production — GDPR Art. 17/20, PDPL UAE/KSA RTL, ISO 27001 plumbing (AES-256-GCM at rest, TLS 1.3, KMS-managed keys, MFA, audit log immutable + 2-year retention), sub-processor disclosures, EU representative
  • Partial — needs work — UK ICO registration pending, PIPEDA OPC process untested, BDSG works-council flow not built, SOC 2 Type I in progress (~64/100), ISO 27701 mapped not certified, ISO 42001 most controls pre-implemented but AIMS not formally established
  • Listed as targets but NOT YET BUILT — CCPA Do-Not-Sell, DPDPA India UX, ROPA, DPO registry, cookie wall, IP allowlist, per-key rate caps, PDPA Singapore, Privacy Act Australia

Rewritten with three explicit sections: Plus a new TSX phase gate sub-section codifying the quality bar going forward: every API route uses withAuth({tier}) + tenant context middleware; every external input parsed by Zod; every state mutation writes to AuditLog + emits OutboxEvent in same transaction; tenant-isolation tests required for every new endpoint; functions ≤30 lines; zero any; a11y AA contrast; RTL via logical properties; generic error responses (no internal leaks).

noteMigration safety

  • Pure additive — zero existing columns altered, no data backfill needed
  • All foreign keys ON DELETE CASCADE for tenant-scoped tables, ON DELETE SET NULL where parent might outlive child (User/Tenant on BreachIncident)
  • All `tenantId` columns indexed for tenant-scoping middleware (per tenant-isolation.md)
  • Reversible via prisma migrate resolve --rolled-back 20260508000000_compliance_schemas
  • No data-loss path — app code that reads these tables tolerates empty results from day 1

noteProduction deploy path

1. Merge PR (CI builds image only — deploy.yml does NOT run migrations per project policy) 2. Run ECS one-shot task: prisma migrate deploy (worker image has the CLI; web image doesn't) 3. Verify: SELECT COUNT(*) FROM "RopaActivity" returns 0 cleanly 4. Tag v5.3.0-alpha.10 (CD picks up the tag and deploys application code)

noteQuality gates

  • Prisma format + validate: clean
  • Typecheck: 15/15 packages, zero errors
  • Lint: 14/14 packages, zero errors (2 pre-existing warnings unchanged)
  • Tests: 510 pass across 26 test files, zero regressions
  • Build: 33.9s (target <60s)

noteNext 5 PRs (already in todo list)

1. CCPA Do-Not-Sell page + footer link + GPC handler 2. Geo-routed cookie consent wall (4 banner variants) 3. DPDPA India consent UX with grievance officer + child-consent 4. ROPA admin surface + 14 seeded activities 5. DPO registry admin + public /legal/dpo

noteAuthorship

Authored by PropVista <support@propvista.io>. ---

v5.3.0-alpha.9

Design tokens adopted from the Stitch design system — Phase 1 of the dashboard reskin. Visual identity shift across the entire app: every screen now renders with the new color palette + type scale + tighter shadows. Zero component-level changes — the existing UI primitives just look different now.

noteWhy this matters

  • Every existing button, card, input, sidebar, modal, status pill, etc. picks up the new look automatically — no per-component edits
  • Architecture was already CSS-variable-driven (var(--hm-*)), so this is a single-file color sweep + a typography preset edit, not a rewrite
  • No component contracts changed — Phase 2 (per-screen reskin) and Phase 3 (new screens) can iterate independently from here

The Stitch design system (Material 3 token architecture, "Calm Efficiency" aesthetic) is a better fit for a high-stakes support platform than the previous Soft Studio palette. The visual language now reads as Linear/Stripe rather than Notion/Intercom — appropriate for agents who stare at this 8 hours a day. The token shift is the highest-leverage change in the reskin plan because:

noteChanged — color palette

Color family shifts from warm neutrals + Soft Studio blue to cool navy + Stitch primary blue. Variable names preserved (no class-name churn anywhere). | Token | Was (Soft Studio) | Now (Stitch) | |---|---|---| | --hm-blue-500 | #1d6cff | #0066cc (primary interactive) | | --hm-blue-600 | #1858d9 | #005cba (hover/pressed) | | --hm-blue-700 | #1244b3 | #004e9f (deepest) | | --hm-text | #0c0a09 warm-black | #0b1c30 cool-navy | | --hm-text-muted | #57534e | #414753 | | --hm-text-subtle | #8a8580 | #727784 | | --hm-bg-subtle | #fafaf9 warm | #f8f9ff blue-tinted | | --hm-bg-muted | #f5f5f4 | #eff4ff | | --hm-bg-inset | #f0efed | #e5eeff | | --hm-border | #e7e5e4 | #c1c6d5 | | --hm-danger | #e11d48 | #ba1a1a (Stitch error) | Sidebar tokens, priority colors, info/success/warning/danger pairs all updated to match. Dark-mode tokens mirror the Stitch helpmesh_design_system_2 spec (cool surfaces, primary #aac7ff inverse).

noteChanged — type scale

Body type drops from 15px → 14px (Stitch's body-md). Headers gain Stitch's negative letter-spacing (-0.02em on h1/h2, -0.01em on h3) for tighter optical density. Lists/tables can use text-sm (now 13px, matching Stitch's body-sm) for the dense data-view density Stitch's spec calls for. | Class | Was | Now | |---|---|---| | text-xs | 12px | 12px (uppercase letter-spacing 0.05em added) | | text-sm | 14px | 13px (Stitch body-sm — for dense lists) | | text-base | 15px | 14px (Stitch body-md — default) | | text-lg | 18px | 18px (Stitch h3) | | text-xl | 22px | 24px (Stitch h2) | | text-2xl | 28px | 32px (Stitch h1) |

noteChanged — shadows

Tightened to Stitch's "extremely diffused, used only for floating elements" rule. Shadow tints shift from warm slate to cool navy. shadow-md and shadow-lg are noticeably lighter — flat surfaces should rely on borders, not elevation.

noteUnchanged on purpose

  • Border radii (sm 4 / md 6 / lg 8 / xl 12) already match Stitch's "Soft" shape language
  • Animation durations + keyframes (fade-in, slide-in, RTL-aware slide-in-end)
  • Component contracts in packages/ui — every Button / Card / Input / etc. continues to work with no source change
  • Tailwind preset variable references — every existing class continues to resolve, just to different colors
  • Sidebar navy aesthetic for dark mode (kept; light mode uses the white surface per Stitch DESIGN.md guidance)

noteWhat you'll see in prod

  • The whole app renders cooler and more navy-leaning. Feels closer to Stripe/Linear than Notion.
  • Body text is one pixel smaller (14px vs 15px), which means slightly more vertical density on lists and the inbox.
  • Borders are slightly more visible (cool grey vs warm beige), giving the "drawn UI" feeling Stitch's spec calls for.
  • Headers feel tighter from the optical kerning.
  • Sidebar active-state uses the new soft blue tint (#dfe8ff) rather than the previous very-light blue.

noteVerified

  • Typecheck 15/15 packages
  • Lint clean (only pre-existing logger.ts no-console warning)
  • 856+ tests pass (no test file references colors directly — all tests pass through unchanged)
  • Build succeeds (38s)
  • Zero TSX file changed — only apps/web/src/app/globals.css (CSS vars) + packages/config/src/tailwind.preset.ts (font-size scale)

noteOut of scope (deferred to Phase 2 + 3)

  • Per-screen layout polish. Stitch's specific layouts for the inbox, ticket detail, customer detail, reports overview, etc. — Phase 2 work, screen by screen.
  • Mobile + responsive. Stitch designed desktop only; HelpMesh needs WCAG AA + mobile per CLAUDE.md. Treat as separate Phase 4 if needed.
  • RTL audit on the new tokens. All token changes are colors/sizes — RTL behavior should be unaffected, but a smoke-test of Arabic mode on /inbox after deploy is worth doing.
  • New screens Stitch designed that don't exist yet (migration_import_tool, web_form_designer, view_as_customer, etc.) — Phase 3, treat each as a feature decision not a redesign.

v5.3.0-alpha.8

OpenAPI response schemas + JSON-LD TechArticle markup + conditional per-guide search. Three DX-extension wins beyond the original 5-gap plan, all on the public-facing surface.

noteAdded — OpenAPI response schemas (16 new named shapes, 25 ops with rich responses)

  • Inbox, Team, Label, User, CannedResponse, CustomField, SlaPolicy
  • KbArticle, KbCollection, WebhookDelivery, AuditLog, SystemHealth
  • Pagination wrappers: ThreadList, MessageList, UserList, KbArticleList, WebhookDeliveryList, AuditLogList, ScheduledMeetingList
  • AI response shapes: AiSuggestReplyResponse, AiSummarizeResponse, AiClassifyResponse, AiSentimentResponse, AiAutoTagResponse, AiSearchArticlesResponse, AiTransformResponse
  • Utility: PresignedUploadResponse, BatchThreadActionResponse, WebhookEventCatalog
  • /threads, /threads/{id}/messages, /threads/{id}/scheduled-meetings
  • /inboxes, /teams, /labels, /users
  • /canned-responses, /custom-fields, /sla-policies
  • /kb/articles, /kb/collections
  • /webhooks, /webhooks/events, /webhooks/{id}/deliveries
  • /system/health, /system/activity
  • All 7 /ai/* endpoints
  • /uploads/presigned and /threads/batch

The alpha.6 ship covered requestBody schemas. This ship covers responses[200/201].schema so generated SDKs in other languages stop typing list responses as any[] and AI op return values as unknown. New named response shapes (registered as named components): GET list endpoints now ship with response schemas (17): Mutation endpoints with rich response schemas (8): A paginatedList() helper generates the { items, nextCursor, hasMore } shape from a single resource schema, so future endpoints can add a typed list response in one line.

noteAdded — JSON-LD TechArticle markup on /docs/migrations pages

  • `TechArticleJsonLd` — emitted on each migration detail page. Includes headline, description, datePublished, dateModified, author + publisher (Organization), proficiencyLevel, optional dependencies. Tells AI assistants this is technical documentation, not marketing copy.
  • `MigrationGuideListJsonLd` — emitted on the index page. CollectionPage with an ItemList of TechArticles. Tells AI assistants "this page links to N upgrade guides" rather than treating it as one undifferentiated article.
  • `BreadcrumbJsonLd` — emitted on all 3 migration pages with full breadcrumb trail (Docs → Migrations → guide).

Three new schema.org structured-data injectors for AI search assistants (ChatGPT, Perplexity, Claude with web access):

noteAdded — Conditional per-guide search

apps/web/src/app/(marketing)/docs/migrations/MigrationsList.tsx is a small client component that owns the list rendering AND a search input. The input only renders when guide count >= SEARCH_THRESHOLD (currently 5). At today's count of 2 guides, the search input is suppressed — typing-then-clearing on a 2-card list adds friction without value. As soon as the 5th guide lands, the search input auto-engages with no further code change. The search filters by title, summary, affects, and version simultaneously. "No matches" state is friendly. URL state isn't persisted (deferred — would need useSearchParams + Suspense boundary; not worth the complexity at this list size).

featureChanged

  • `packages/core/src/openapi/registry.ts` — bumped info.version from 1.12.0 to 1.13.0
  • `packages/core/src/openapi/registrations.ts` — added 16 named response shapes + 25 ops upgraded to rich responses; the existing 9 rich requestBody endpoints now also have rich response schemas where applicable
  • `apps/web/src/components/marketing/JsonLd.tsx` — added TechArticleJsonLd + MigrationGuideListJsonLd exports, mirroring the existing OrganizationJsonLd / WebsiteJsonLd / BreadcrumbJsonLd pattern
  • `apps/web/src/app/(marketing)/docs/migrations/page.tsx` — extracted MigrationCard to a client component (MigrationsList.tsx) that ALSO owns search; injected BreadcrumbJsonLd + MigrationGuideListJsonLd at page top
  • `apps/web/src/app/(marketing)/docs/migrations/error-codes-v5-3-0-alpha-4/page.tsx` — injected BreadcrumbJsonLd + TechArticleJsonLd
  • `apps/web/src/app/(marketing)/docs/migrations/openapi-spec-versions/page.tsx` — injected BreadcrumbJsonLd + TechArticleJsonLd; updated to reflect the new spec 1.13 row
  • Migration index entry for OpenAPI — title and summary updated to mention the 1.13 response-schema upgrade

noteTests

  • `packages/core/src/openapi/__tests__/registrations.test.ts` — added 26 lock-in assertions for the new response-schema endpoints. Test count: 43 → 69.
  • All other tests still pass. Web 301, db 192, ai 35, auth 4, billing 3, reporting 19, worker 26, sdk 11, slack 8 — total 856+.

noteVerified

  • Typecheck 15/15 packages
  • Lint clean (only pre-existing logger.ts + api-handler.ts no-console warnings)
  • Build succeeds, all 3 migration pages still prerender, MigrationsList client bundle is small (~280B over the prior server-only render)
  • No route handler changed — runtime behavior untouched, this is a pure spec + docs change

noteOut of scope (deferred)

  • POST/PATCH response schemas for catalog mutatorsPOST /inboxes etc. currently still have responses: { 201: { description: 'X created.' } } without a schema. These return the created resource — could add schema: InboxResponseSchema etc. in a small follow-up.
  • URL-state for search — when guide count crosses ~10 and search becomes useful, persist the query string to the URL via useSearchParams so links to filtered views are shareable. Today (2 guides) this would be over-engineering.
  • Polymorphic Automation/SavedView response schemas — these use z.record(z.unknown()) for the same reason as request bodies (genuinely tenant-defined). A future Automation-DSL ship would replace these with discriminated unions in both directions.

v5.3.0-alpha.7

Per-version migration guides at `/docs/migrations`. Closes Fix 4 from the EOD DX re-score (8.0/10). New top-level docs section dedicated to upgrade workflows — index page + two real migration guides shipped with this release. Targets DX Upgrade Path 7 → 8.

noteWhy this matters

Migration content has been buried inside CHANGELOG entries since v4. For an integrator hitting an err.code === 'HM-AUTH-001' error after upgrading past v5.3.0-alpha.4, the right answer was buried 50 lines into a changelog entry under a "Behaviour notes" sub-heading. The new section makes the upgrade-relevant content first-class, indexed, SEO-discoverable, and cross-linked from both /docs and /changelog.

featureAdded

  • `/docs/migrations` — index page listing every guide with severity badges (Breaking / Soft change / Informational), version + date metadata, target audience callouts, and a "How HelpMesh handles breaking changes" section explaining the URL-versioning + spec-versioning + LEGACY_CODE_ALIASES + SDK strategies.
  • `/docs/migrations/error-codes-v5-3-0-alpha-4` — full migration guide for the numeric→semantic error code switch:
  • `/docs/migrations/openapi-spec-versions` — soft-change guide for the OpenAPI 1.10 → 1.11 → 1.12 evolution:

- TL;DR breaking-change banner - Why it matters (overloaded HM-AUTH-001 example) - Format explainer (HM-{DOMAIN}-{CONDITION}) - 4-step migration procedure - Before/after code samples in Node.js + Python - Translation map strategy with SEMANTIC_TO_LEGACY example for downstream consumers - 10-row mapping table covering the highest-frequency retired numerics - 39-row reference table of every public-facing semantic code with HTTP status + meaning - Status code parity callout (HTTP 401→404 correction for User-Not-Found sites) - SDK note pointing to @helpmesh-io/sdk ≥ 5.1.0 - Spec-version vs product-version explainer - Version-by-version table of what shipped in each spec bump - Re-generation commands for TypeScript, Python, Go clients - "Why not bump the URL path version" rationale

featureChanged

  • `/docs` index page — added "Migration Guides" card to Resources section between Changelog and Security cards. Uses GitBranch icon from lucide-react for visual distinction from API/Webhooks/Email cards.
  • `/changelog` page — top-of-page banner pointing to /docs/migrations for upgrade work. Two-sentence callout above the first release entry.

noteLayout pattern (for future migrations)

  • Path: apps/web/src/app/(marketing)/docs/migrations/<slug>/page.tsx
  • Slug naming: <topic>-v<version> (e.g. error-codes-v5-3-0-alpha-4) when a single version, or <topic>-versions (e.g. openapi-spec-versions) when spanning a range.
  • Required structure: TL;DR severity banner → Why it matters → Migration steps → Before/after code samples → Reference tables → Related links.
  • Reuse: PageHeader from @/components/marketing/primitives, lucide-react icons, the same Tailwind classes as /docs/webhooks and /docs/email-setup.
  • Cross-link both ways: index links to detail pages, detail pages link back to index AND to the matching changelog anchor.

noteVerified

  • Typecheck 15/15 packages
  • Lint clean (only pre-existing logger.ts + api-handler.ts no-console warnings)
  • All 830+ tests still pass
  • Build succeeds; all 3 new pages prerendered (/docs/migrations, /docs/migrations/error-codes-v5-3-0-alpha-4, /docs/migrations/openapi-spec-versions)

noteOut of scope (deferred)

  • Older breaking-change guides — v5.0.0 GA was opt-in (no breaking change for existing tenants), so it doesn't warrant a guide. The v4 → v5 boundary itself is also opt-in. If a true breaking change happens in a future v5.x or v6.x ship, it gets a guide following the layout pattern above.
  • Search across migration guides — there are 2 today. When that grows past ~10, add the docs sidebar search pattern from /docs/api.
  • JSON-LD `TechArticle` markup — the migration pages have OpenGraph + canonical metadata, but not the structured-data TechArticle markup that would help AI search assistants (ChatGPT, Perplexity) cite them by name.

v5.3.0-alpha.6

OpenAPI requestBody schemas: 9 → 34. Closes Fix 2 from the EOD DX re-score (8.0/10). Generated SDKs in other languages (Python, Go, Ruby, etc.) no longer fall back to body?: any for the bulk of the API surface — every high-traffic mutating endpoint now exposes a real Zod-derived schema in the spec. Targets DX API/CLI/SDK 8 → 9.

noteWhy this matters

Before this ship, only 9 of 39 operations in the public OpenAPI spec carried rich requestBody schemas. The rest had a summary and nothing else, which meant tools like openapi-generator produced clients with body: object or body?: any for every other endpoint. Useful as a path index; useless as a contract. Anyone generating an SDK from /api/v1/openapi had to fall back to typed wrappers maintained by hand. This ship re-declares the request body schemas inline in the OpenAPI registry so the spec carries them publicly. Route handlers continue to own their own runtime validation — the registrations are the public-facing contract.

noteAdded — 25 endpoints upgraded from summary-only to rich requestBody

  • POST /api/v1/threads/{id}/assignAssignThreadRequest (assigneeId, teamId, both nullable)
  • POST /api/v1/threads/{id}/snooze + DELETESnoozeThreadRequest (until: ISO 8601 future)
  • POST /api/v1/threads/{id}/labels + DELETEThreadLabelRequest (labelId)
  • POST /api/v1/threads/{id}/mergeMergeThreadRequest (sourceThreadId)
  • POST /api/v1/threads/{id}/read — no body, fully documented
  • POST /api/v1/threads/{id}/ai-draft — no body, fully documented
  • POST /api/v1/threads/batchBatchThreadActionRequest (threadIds, action, params)
  • PATCH /api/v1/threads/{id}/scheduled-meetings/{mid}UpdateMeetingRequest
  • DELETE /api/v1/threads/{id}/scheduled-meetings/{mid}CancelMeetingRequest (reason)
  • PATCH .../attendanceMeetingAttendanceRequest (outcome enum)
  • PATCH .../recordingMeetingRecordingRequest (url, note)
  • GET /api/v1/threads/{id}/scheduled-meetings — registered with path params
  • POST /api/v1/ai/suggest-replyAiSuggestReplyRequest (thread_id, tone enum)
  • POST /api/v1/ai/summarizeAiThreadIdRequest
  • POST /api/v1/ai/classifyAiThreadIdRequest
  • POST /api/v1/ai/sentimentAiThreadIdRequest
  • POST /api/v1/ai/auto-tagAiAutoTagRequest (thread_id, apply boolean)
  • POST /api/v1/ai/search-articlesAiSearchArticlesRequest (query, limit)
  • POST /api/v1/ai/transformAiTransformRequest (text, action, targetLocale BCP-47)
  • POST /api/v1/inboxesCreateInboxRequest (channel enum, widgetKey auto-gen note)
  • POST /api/v1/teamsCreateTeamRequest
  • POST /api/v1/labelsCreateLabelRequest (color regex)
  • POST /api/v1/users/inviteInviteUserRequest (role enum)
  • POST /api/v1/canned-responsesCreateCannedResponseRequest (shortcut regex)
  • POST /api/v1/custom-fieldsCreateCustomFieldRequest (fieldType enum, options)
  • POST /api/v1/sla-policiesCreateSlaPolicyRequest
  • POST /api/v1/kb/articlesCreateKbArticleRequest (slug regex, status enum, seoMeta)
  • POST /api/v1/kb/collectionsCreateKbCollectionRequest
  • POST /api/v1/kb/articles/{slug}/feedbackKbArticleFeedbackRequest
  • POST /api/v1/kb/searchKbSearchRequest
  • POST /api/v1/privacy/consentPrivacyConsentRequest (purpose enum, granted bool, source enum)
  • POST /api/v1/privacy/exportPrivacyExportRequest (format enum)
  • POST /api/v1/privacy/deletePrivacyDeleteRequest (hard bool with description)
  • POST /api/v1/auth/forgot-passwordForgotPasswordRequest
  • POST /api/v1/auth/reset-passwordResetPasswordRequest
  • POST /api/v1/auth/change-passwordChangePasswordRequest
  • POST /api/v1/tenant/email-domainTenantEmailDomainRequest (FQDN regex)
  • POST /api/v1/tenant/email-domain/verify — registered
  • POST /api/v1/uploads/presignedPresignedUploadRequest (filename, contentType, sizeBytes ≤10 MiB)
  • POST /api/v1/webhooks/{id}/reset-counter — registered

Thread sub-resources: Scheduled meetings (full lifecycle now in spec): AI operations (whole namespace previously unregistered): Catalog mutators: KB: Privacy (GDPR): Auth (password lifecycle): Tenant settings: Uploads: Webhook utilities:

featureChanged

  • `packages/core/src/openapi/registry.ts` — bumped info.version from 1.11.0 to 1.12.0 to reflect the contract expansion
  • `packages/core/src/openapi/registrations.ts` — added 11 reusable enums (Channel, Priority, ThreadStatus, AuthorType, Role, KbStatus, Tone, ConsentPurpose, ConsentSource, CustomFieldType) and 26 named request schemas; existing 9 rich endpoints preserved unchanged

noteTests

  • `packages/core/src/openapi/__tests__/registrations.test.ts` — added 31 lock-in assertions for the new rich-body endpoints and 32 path-existence assertions covering the full registered surface. Test count: 11 → 43.

noteVerified

  • Typecheck 15/15
  • Lint clean (only the two pre-existing no-console warnings in logger.ts and api-handler.ts)
  • 830+ tests pass (was 791)
  • No route handler changed — runtime validation paths untouched, this is a pure spec change

noteOut of scope (deferred)

  • Response shapes — this ship focuses on requestBody. Many GET endpoints still return summary-only response descriptions. A follow-up could schematize Customer, Inbox, Team, Label, Automation, SlaPolicy, etc., as named response objects.
  • Complex polymorphic schemasAutomation triggers/conditions/actions and SavedView.filters use z.record(z.unknown()) here because their full shape is genuinely tenant-defined. A future Automation-DSL ship could replace these with a discriminated union.

v5.3.0-alpha.5

DX polish bundle: completes Fix 1 sweep + Fix 3 (issue templates) + Fix 5 (community navigation). Closes the "lower-priority" gaps from the EOD DX re-score (8.0/10) that translate to internal contributor experience. The repo is private; the public community surface (Option D) is deferred to a dedicated session.

fixFixed

  • `apps/web/src/server/middleware/rate-limit.ts` — the 429 response was still emitting HM-RATE-001 despite v5.3.0-alpha.4's claim that all numeric codes were migrated. The file was modified in the working tree during alpha.4's typecheck but missed from the commit's git add. Now correctly emits HM-RATELIMIT-EXCEEDED. Anyone hitting a rate limit between alpha.4 and now saw the legacy code; alpha.5 closes that gap.

featureAdded

  • `.github/ISSUE_TEMPLATE/` — three structured issue templates:
  • `.github/pull_request_template.md` — what changed / why / how to test / risk + rollback / quality-gate checklist
  • GitHub Discussions enabled on jolly-bit/helpmesh (via API) — surface for "what do you think about…" prompts that aren't bugs

- bug_report.yml — version field, surface dropdown, repro steps, request ID, redact-secrets reminder, security-issue carve-out - feature_request.yml — problem-first framing, importance scale (cosmetic / frictional / blocker), workarounds field - config.yml — disables blank issues, routes security to SECURITY.md / Discussions for questions / docs URL / npm SDK / support email

featureChanged

  • `CONTRIBUTING.md` — clearer routing guidance: Discussions for questions/ideas, Issues (using new templates) for bugs, security via email per SECURITY.md
  • `README.md` — Contributing section now lists three explicit routing paths instead of the ambiguous "start with a discussion issue"

noteWhy this is internal-team-only DX

The repo is private. External SDK users on @helpmesh-io/sdk cannot see Issues, Discussions, or repo source. The Fix 3+5 work helps internal contributors and team members with repo access — it doesn't move the public DX score.

noteWhat's deferred

Public community repo (Option D) — Stripe/Twilio pattern: keep the platform private, carve out jolly-bit/helpmesh-community as a public mirror containing only the SDK source + docs + Issues + Discussions. The right architectural answer for a private platform shipping a public-MIT npm SDK. Estimated 2-4 hours of focused work in a dedicated session.

noteVerified

  • Working tree clean post-commit (only .tsbuildinfo artifacts)
  • 791 tests still pass
  • Typecheck 15/15
  • The rate-limit fix verified by re-running the bulk-replace and confirming the working tree matches what alpha.4 should have committed

v5.3.0-alpha.4

Error code consistency: numeric → semantic. Closes Fix 1 from the EOD DX re-score (8.0/10). Every public-facing error code now uses the SCREAMING_SNAKE_CASE form that names the actual condition (HM-AUTH-MISSING, HM-VALIDATION-FAILED, HM-THREAD-NOT_FOUND) instead of opaque numeric codes (HM-AUTH-001). For an integrator reading a 401 response, the code IS the documentation. Targets DX Errors 9 → 10.

noteWhy this matters

Before this ship, HM-AUTH-001 meant "Unauthorized" on one route, "Sign-in required" on another, "User not found" on a third, and "Invalid email or password" in the registry. Same code, four different conditions. An SDK consumer trying to handle the error class couldn't if (err.code === 'HM-AUTH-001') reliably — they had to parse the message string. The semantic codes split each numeric into 1-3 specific codes that name what actually went wrong.

featureChanged

  • `packages/core/src/errors/registry.ts` — rewrote the ErrorRegistry with semantic codes (e.g. AUTH_MISSING: { code: 'HM-AUTH-MISSING', ... } instead of AUTH_INVALID_CREDENTIALS: { code: 'HM-AUTH-001', ... }). 47 entries total. Added LEGACY_CODE_ALIASES reverse-lookup map so external dashboards or alerting that pinned on the old numeric codes can translate during the transition window.
  • ~120 route call sites swept across apps/web/src/app/api/. Multi-meaning numeric codes split by intent:
  • `HM-RATE-001` and `HM-RATELIMIT-001` both collapsed to canonical HM-RATELIMIT-EXCEEDED (the codebase had two codes for the same condition).
  • OpenAPI spec example updated: code: { example: 'HM-VALIDATION-FAILED' } with a description explaining the semantic-suffix convention.
  • SDK test updated to use HM-RATELIMIT-EXCEEDED instead of HM-RATE-001.

- HM-AUTH-001HM-AUTH-MISSING (38 sites, "Authentication required") / HM-AUTH-USER_NOT_FOUND (2 sites, "User not found") - HM-AUTH-002HM-AUTH-PASSWORD_NOT_SET / HM-AUTH-PASSWORD_INCORRECT - HM-AUTH-003HM-AUTH-FORBIDDEN (5 sites, "Insufficient permissions") / HM-AUTH-RESET_LINK_INVALID (2 sites, password reset) - HM-AI-001HM-AI-DISABLED (8 sites, "AI features are disabled") - HM-AI-002HM-AI-PROVIDER_ERROR (4 sites, upstream 503) / HM-AI-SEARCH_FAILED (1 site) / HM-VALIDATION-FAILED (1 site, the "no messages to draft a reply" case which is actually a precondition error, not an AI error)

noteBehaviour notes

  • Backwards compatibility: response bodies now emit semantic codes. Any integrator code that pinned on numeric codes (e.g. if (err.code === 'HM-AUTH-001')) will need to update. The LEGACY_CODE_ALIASES map exists to help downstream consumers translate, but the responses themselves are not bilingual — they emit the new codes.
  • Internal code path: routes that imported the registry's named entries (e.g. ErrorRegistry.AUTH_MISSING) keep working because the named keys are stable; only the emitted code string changed.
  • HTTP status preserved: every status code matches the prior behavior. The semantic rename is purely a string-level change.
  • Two regression-guard tests retained that pin "no failure ever returns the legacy HM-TENANT-001 numeric code" — those guard against accidental reversion.
  • Some specifically-correct status fixes shipped along the way: the two "User not found" sites were emitting 401 with HM-AUTH-001. Those should be 404 HM-AUTH-USER_NOT_FOUND (the user could be deleted, not just missing creds). Fixed.

noteThe complete numeric → semantic mapping

44 numeric codes retired. See LEGACY_CODE_ALIASES in packages/core/src/errors/registry.ts for the full one-to-many map. Highlights: | Old | New | |---|---| | HM-AUTH-001 | HM-AUTH-MISSING / HM-AUTH-INVALID_CREDENTIALS / HM-AUTH-USER_NOT_FOUND | | HM-AUTH-002 | HM-AUTH-TOKEN_EXPIRED / HM-AUTH-PASSWORD_INCORRECT / HM-AUTH-PASSWORD_NOT_SET | | HM-AUTH-003 | HM-AUTH-FORBIDDEN / HM-AUTH-RESET_LINK_INVALID | | HM-VALIDATION-001 | HM-VALIDATION-FAILED | | HM-AI-001 | HM-AI-DISABLED / HM-AI-PROVIDER_ERROR | | HM-AI-002 | HM-AI-RATE_LIMITED / HM-AI-SEARCH_FAILED | | HM-CONFLICT-001 | HM-CONFLICT-DUPLICATE | | HM-WIDGET-003 | HM-WIDGET-SESSION_REQUIRED | | HM-RATE-001 + HM-RATELIMIT-001 | HM-RATELIMIT-EXCEEDED | | ...and 35 more | see registry.ts |

noteTests

  • 791 tests pass on v5.3.0-alpha.4 (was 780): core 200 · web 301 · db 192 · sdk 11 · ai 35 · worker 26 · reporting 19 · auth 4 · billing 3.
  • Typecheck 15/15 packages · Lint clean.

noteVerified

  • All numeric codes outside LEGACY_CODE_ALIASES retired (verified by grep).
  • One regression guard test ("no failure returns HM-TENANT-001 legacy code") explicitly preserved.
  • No HTTP status code changed except the two correct fixes (401 → 404 for the two "User not found" sites).

v5.3.0-alpha.3

Logo refresh. Updated apps/web/public/logo.svg to the v1-full-twotone variant with the larger "HelpMesh" wordmark. Same artwork — just a typography upgrade. The text now matches the icon's 64px height (was 28px font in a 64px frame, now 48px) and the canvas is 304×64 instead of 244×64. All 9 referenced surfaces (marketing nav + footer, auth layout, legal layout, status page, blog JSON-LD, OG/JSON-LD logo URL, app/auth fallback, email outbound-sender header) pick up the change automatically since they all reference /logo.svg.

featureChanged

  • apps/web/public/logo.svg — replaced with the larger-wordmark version. ViewBox 0 0 300 64 (was 244 64). Text font-size 48 (was 28). Letter-spacing -1 (was -0.5). Y baseline 48 (was 24, with a 20-unit Y translate that the new version drops). Same dot-grid icon, same #0066CC brand blue, same #1a1a1a Mesh wordmark color.

noteBehaviour notes

  • All <img src="/logo.svg" className="h-7 ..." /> callers keep height fixed at 28px; the wider new aspect ratio (~4.75:1 vs ~3.81:1) means the rendered logo becomes ~25% wider on screen. Intentional — the logo wordmark is now more prominent at the same height.
  • apps/web/public/icon.svg (the icon-only mark used as favicon / apple-touch-icon) is unchanged. Only the full lockup was updated.
  • tenant.logoUrl per-tenant uploaded branding logos are unaffected — those are a separate BYOD branding system.
  • Email templates that reference https://helpmesh.io/logo.svg (in packages/channels/src/email/outbound-sender.ts:523) automatically pick up the new logo on the next send since the URL is unchanged.

noteVerified

  • Web typecheck passes (asset-only change, no code touched)
  • 9 callers identified by grep, all unchanged
  • File diff confirms: width, viewBox, text transform, font-size, letter-spacing, y baseline. No path geometry changed.

v5.3.0-alpha.2

Hotfix: Dockerfile glob-match for Prisma version directory. v5.3.0-alpha.1 deploy failed because two COPY lines in the Dockerfile hardcoded @prisma+client@5.22.0_prisma@5.22.0 — pnpm's content-addressed directory name. With Prisma 6.19.3 the directory is @prisma+client@6.19.3_prisma@6.19.3_typescript@5.9.3__typescript@5.9.3 and the build couldn't find it.

fixFixed

  • `Dockerfile` lines 57-58 — replaced hardcoded @prisma+client@5.22.0_prisma@5.22.0 with @prisma+client@* glob. Now version-agnostic — survives Ship 2 (Prisma 7) and any future Prisma upgrades without needing another Dockerfile edit.

noteWhy

Caught once. Fixed once. The pnpm .pnpm directory naming pattern is @prisma+client@<version>_prisma@<version>[_typescript@<v>] — any version bump changes the directory name and breaks a hardcoded path. The glob match works because the pnpm hoist guarantees a single content-addressed entry per package version, so @prisma+client@* resolves to exactly one directory at build time.

noteVerified

  • Local typecheck 15/15, 780 tests pass (Prisma 6.19.3 client active)
  • Dockerfile fix is build-time only — no runtime change

v5.3.0-alpha.1

Prisma 5.22.0 → 6.19.3 (Ship 1 of 5→6→7 upgrade plan). Major-version dependency bump. Two changes only: package version pins and the fullTextSearchfullTextSearchPostgres preview-feature rename. Possible because v5.2.2 (Ship 0) already migrated the tenant-scoping middleware from $use (removed in Prisma 6) to $extends. Alpha tag because this is the first major Prisma bump in prod — drops to v5.3.0 GA after a soak window confirms stability.

featureChanged

  • `prisma` + `@prisma/client`^6.19.3 in packages/db/package.json, packages/core/package.json, apps/worker/package.json.
  • `packages/db/prisma/schema.prisma`previewFeatures = ["fullTextSearch", "postgresqlExtensions"] renamed to ["fullTextSearchPostgres", "postgresqlExtensions"]. The PostgreSQL full-text-search preview feature was renamed in Prisma 6.0; the old name throws a validation error.

noteWhy this is small

The hard work happened in v5.2.2 (Ship 0). Removing $use would have broken the tenant-scoping middleware foundation; we anticipated this by migrating to $extends first while staying on Prisma 5.22. Now the version bump is a routine dependency upgrade.

noteVerified breaking-change matrix

Every documented Prisma 6 breaking change was checked against the codebase: | Change | Status | |---|---| | $use middleware removed | Already migrated to $extends in v5.2.2 — no impact | | Prisma.MiddlewareParams type removed | No remaining usages — covered by Ship 0 | | fullTextSearchfullTextSearchPostgres preview rename | Applied in this commit | | Implicit m-n relation tables get a primary key | Schema has zero implicit m-n relations (verified — only candidate Label.children is a 1-to-many self-relation with parentId FK) | | Bytes field type → Uint8Array (was Buffer) | Schema has zero Bytes columns (verified) | | NotFoundError removed | Codebase has zero references (verified by grep) | | Reserved model names (async/await/using) | No conflicts — all model names are domain nouns | | Min Node 22.11.0, TS 5.1+ | Already satisfied — Node 22 LTS + TS 5.7 |

noteTests

  • 780 tests pass on Prisma 6.19.3 (same as on Prisma 5.22 after Ship 0)
  • 192 db · 200 core · 301 web · 35 ai · 26 worker · 19 reporting · 4 auth · 3 billing
  • Typecheck 15/15 packages · Lint clean (only pre-existing warnings)

noteOperational state

  • No migration in this ship — pure dependency bump. Migration task family stays at rev 10.
  • Deploy pipeline (deploy.yml) builds new web + worker images at the v5.3.0-alpha.1 tag and rolling-updates ECS services. Same path as every other tag-push.
  • Rollback procedure: see docs/runbooks/21-prisma-upgrade-rollback.md (now battle-tested from the 2026-05-06 first-attempt revert).

noteNext ship

Ship 2: Prisma 6.19.3 → 7.8.0 — once v5.3.0-alpha.1 is stable in prod for a few hours. Ship 2 is the more delicate of the three because it touches the migrate-task entrypoint via the new prisma.config.ts requirement.

v5.2.2

Tenant-scoping middleware migrated from `$use` → `$extends`. Prerequisite for the upcoming Prisma 6 upgrade — $use was removed in Prisma 6.0, so this rewrite is the gating Ship 0 of the 5→6→7 upgrade plan. Stays on Prisma 5.22.0 (no version bump). Pure architecture migration with full behavioral parity. No customer-visible change.

noteRefactor

  • `packages/db/src/middleware/tenantScope.ts` — rewrote the tenant-scoping middleware from $use (deprecated) to $extends (Prisma 5 GA, Prisma 6+ required). Extracted the decision logic into a pure function applyTenantScope({ model, operation, args, store }) so unit tests don't need to mock the full Prisma client. The new tenantScopeExtension is a thin Prisma.defineExtension wrapper that calls applyTenantScope from inside query.$allModels.$allOperations.
  • `packages/db/src/client.ts`client.$use(tenantScopeMiddleware)client.$extends(tenantScopeExtension). New ExtendedPrismaClient type exported (it's ReturnType<typeof createPrismaClient> because $extends returns a DynamicClientExtensionThis<...> shape that's not the bare PrismaClient).
  • `packages/db/src/index.ts` — public exports updated. Removed tenantScopeMiddleware; added tenantScopeExtension, applyTenantScope, ExtendedPrismaClient (type-only).
  • `packages/db/src/factories/tenant.factory.ts` — factories now type their prisma: parameter as ExtendedPrismaClient instead of bare PrismaClient (the extension changes the client's type signature; the delegate API surface — prisma.tenant.create(), etc. — is unchanged).

noteBehavioral parity guarantees

  • Same 17 tenant-scoped models, 3 non-tenant models, 6 transitively-scoped models (no list change)
  • Tenant model special case (scoped by id, not tenantId)
  • AsyncLocalStorage propagation (verified by a standalone Node ALS test outside the suite)
  • HELPMESH_TENANT_STRICT=1 env-var triggers strict mode (throws on undefined store)
  • Default-permissive when ALS store is undefined (Next.js App Router escape hatch)
  • Always-throws when store has explicit null tenantId (programming error, regardless of strict mode)
  • All operation branches preserved (find/count/aggregate/groupBy/create/createMany/update/updateMany/delete/deleteMany/upsert)

Every behavior of the old $use middleware is preserved:

noteTests

  • 3 test files migrated from tenantScopeMiddleware(params, next) to applyTenantScope({ ... }) direct calls. Same coverage, cleaner shape.
  • +8 new operation-branch tests locking parity that was previously implicit: createMany array form, createMany object form, upsert (where + create both injected, update NOT mutated), updateMany, deleteMany, findUniqueOrThrow, findFirstOrThrow, createManyAndReturn.
  • Test count: 185 → 192 (db package). All other packages unchanged (200 core, 301 web, 35 ai, 26 worker, 19 reporting, 4 auth, 3 billing).
  • Total: 780 tests pass. Typecheck 15/15. Lint clean.

noteWhy this matters

$use is the foundation of HelpMesh's multi-tenant isolation. Per .claude/rules/tenant-isolation.md: *"A cross-tenant data leak ends this business. These rules are absolute."* Bundling this rewrite with the Prisma 6 version bump (the original Ship 1 plan) would have made the version bump's blast radius dangerously large. Splitting it out as Ship 0 means we validate the new architecture in prod before taking the version-bump risk. The Prisma 6 upgrade now becomes a small, contained ship.

noteNext ships

  • Ship 1: Prisma 5.22 → 6.19.3 — once v5.2.2 is stable in prod for a few hours
  • Ship 2: Prisma 6.19.3 → 7.8.0 — once Ship 1 lands

See docs/runbooks/21-prisma-upgrade-rollback.md for the rollback procedure.

noteVerified

  • Typecheck: 15/15 packages · Lint: clean (only pre-existing warnings) · Tests: 780 pass · ALS propagation: verified standalone

v5.2.1

Lower-priority DX polish bundle: M3 + M5 + M6. Closes the last actionable items from the May 6 PM re-review. Three independent surfaces — docs feedback widget (auth-required, anti-abuse hardened), OpenAPI vs product versioning explainer, root package.json hygiene — bundled because they share a release window. Patch bump because none of these change a public API contract.

featureAdded

  • M3 — "Was this page helpful?" widget on `/docs/api` with a fully hardened backend.
  • M5 — OpenAPI version vs product version explainer. Spec info.description and a visible callout on /docs/api both clarify that info.version (e.g. 1.11.0) tracks the API contract while the HelpMesh product version (e.g. 5.2.1) bumps far more often. Pin to the URL path — /api/v1 — that's the only breaking-change boundary that matters.

- `POST /api/v1/docs/feedback` — authenticated session required (no anonymous submissions). Per-user-per-path uniqueness at the DB layer means re-submission flips the existing vote in place — no duplicate rows. Comment field captured ONLY for negative feedback (helpful = false); positive "great article!" noise is rejected silently so every saved comment is actionable. Path validated against ^/docs(/[a-z0-9-]+)*$ regex; UA truncated to 256 chars; comment capped at 1000 chars; auth-preset rate limit (30 submissions/hour per user). Honeypot field (website) — bots auto-fill, humans never see it; honeypot trip returns { ok: true } without writing so attackers don't learn they've been detected. - `GET /api/v1/admin/docs-feedback` — SUPER_ADMIN-only retrieval. Cross-tenant aggregate signal (intentional — this is platform-level not customer-data). Filters: path, helpful, from, to, cursor, limit (max 200). Includes user + tenant attribution. - `DocsFeedback` table — new Prisma model. userId and tenantId FKs both nullify on delete (onDelete: SetNull) so historical aggregate signal survives GDPR right-to-erasure / tenant offboarding. Same pattern as AuditLog.actorId. Indexes: unique (path, userId) for vote-flip semantics, (path, createdAt) for time-series queries, (helpful, createdAt) for negative-feedback monitoring, (tenantId) for per-tenant slicing. - Widget UI at the bottom of /docs/api. Three states: initial (👍/👎), detail (textarea, only after 👎), thanks (final). Suppresses re-prompt for 30 days via localStorage. Graceful failure on 4xx/5xx — shows "thanks" anyway so a hostile network doesn't see endless retry. Authenticated-only at the API layer; unauthenticated visitors who click see a friendly "sign in to send feedback" message rather than a raw 401. - OpenAPI spec exposes both endpoints with the full DocsFeedbackRequest schema so generated SDKs in any language carry the contract. - 43 new tests covering path regex (acceptance + traversal/injection rejection), Zod schema (positive/negative/comment-cap/honeypot), comment policy (kept-on-negative, discarded-on-positive, whitespace handling), honeypot detection, and UA truncation.

fixFixed

  • M6 — root `package.json` metadata hygiene. Added homepage, repository, bugs. License changed from UNLICENSED to MIT to match the actual LICENSE file at repo root (root has been MIT since v5.1.3 — the package.json was just stale). Root version bumped from 4.7.65.2.1 so it stops drifting behind the actual ship cadence (per-app package.json files were getting bumped while root was forgotten).

noteSecurity posture (M3)

The docs-feedback surface was designed to never become an abuse hole. Every identified threat has an explicit mitigation: | Threat | Mitigation | |---|---| | Anonymous spam / vote-stuffing | Auth required at the route — no anonymous submissions, ever | | Compromised-session abuse | Per-user rate limit (30/hour) via existing enforceLimit | | Vote duplication | DB-level unique constraint on (path, userId) — re-submission upserts, never duplicates | | Bot scraping the form | Honeypot field (website) returns success without writing | | XSS via comment | Server-rendered with React's default escaping; never echoed to public surfaces | | SQL injection | All access via Prisma — parameterized queries, no raw SQL | | CSRF | NextAuth session cookies are SameSite=lax; same-origin enforced at the cookie layer (existing shouldSkipCsrf middleware) | | Path injection | Strict regex ^/docs(/[a-z0-9-]+)*$ — rejects .., query strings, fragments, URL-encoded payloads, protocol-relative URLs | | Comment storage exhaustion | 1000-char cap at the schema layer | | UA-header storage exhaustion | 256-char truncation at the route layer | | Tenant data leak | Endpoint is platform-level — no tenant data is read or modified, ever. Write-only from public surface | | Compliance / right-to-erasure | onDelete: SetNull on both userId and tenantId FKs | | Logging PII | Structured logs include path, helpful, userId, feedbackId — never the comment text, never the UA |

noteDatabase

  • New migration: 20260506110000_docs_feedback. 100% additive — creates DocsFeedback table with two nullable FKs to User and Tenant, four indexes (one unique). No mutations to existing rows. Zero downtime risk; an old image cannot collide with the new schema.
  • Run prisma migrate deploy via the ECS one-shot migrate task before rolling the v5.2.1 image (per the established BYOD-era pattern).

noteTests

  • 200 core (+1 OpenAPI versioning assertion) · 301 web (+43 docs feedback helpers) · 185 db · 35 ai · 26 worker · 19 reporting · 4 auth · 3 billing · = 773 tests pass.

noteVerified

  • Typecheck: 15/15 packages · Lint: clean (only pre-existing warnings) · Build: chained from typecheck · Tests: 773 pass

v5.2.0

Docs UX upgrade: language tabs + search + copy buttons. Closes the last two visible items from the May 6 PM re-review (H3 + H7). The /docs/api page now feels like Stripe / Twilio / Anthropic territory: pick your language, search across all 7 endpoint groups, copy any code block in one click. Bumped to a minor (5.2.0, not 5.1.4) because the docs UX is a meaningful product feature, not a polish patch.

featureAdded

  • Language tabs (H3) — three-button toggle at the top of /docs/api: curl / Node / TypeScript. The active language flips example shape across the whole section.
  • Docs search (H7 part 1) — search box at the top of the sidebar. Filters endpoints across all 7 groups by HTTP method, path, or description. Shows match count, supports clear (✕ button), and surfaces the originating group label above each search hit. Switching to a sidebar group clears the search.
  • Copy buttons on every code block (H7 part 2) — fade-in copy button in the top-right of every <pre>. Uses navigator.clipboard.writeText. Shows "Copied" feedback for 1.5s on success. Gracefully falls back (button stays clickable but no-ops) on browsers that block clipboard access — the existing select-and-copy still works.
  • Endpoint cards now expose `samples` field in the type. samples?: { curl?: string; node?: string; ts?: string } — endpoints without samples keep the existing curl-only behaviour, no regression.

- 4 high-traffic endpoints carry hand-written SDK samples (POST /threads, POST /threads/:id/reply, POST /threads/:id/take-over, POST /threads/:id/suppress-ai, POST /threads/:id/schedule-meeting) that import @helpmesh-io/sdk and show idiomatic Node + TS calls. The TS variant adds import type statements for the typed return. - All other endpoints get auto-generated samples — autoCurl() produces a runnable curl with $RESOURCE_ID-style placeholders; autoNode() produces a stub that points at the SDK's npm page so developers know it's there. - When the active language is Node or TS, a small npm install @helpmesh-io/sdk hint appears next to the tab selector. Compounds with the v5.1.0 SDK ship. - Pure client-side filter — no Algolia, no fetch round-trip. Works the moment you start typing.

noteBehaviour notes

  • The requestExample field (the JSON-shape body in the existing data) is preserved as a "Request Body Schema" code block when the curl tab is active — that's where developers learn what shape the API expects. The Node/TS tabs hide it because the SDK's typing replaces it.
  • Search query takes precedence over group selection — the section header changes to "Search results" while a query is active. Clearing the search (✕ or empty) drops back to the previously-selected group.
  • The 404-style "no matches" panel inside the search tray prompts the developer to clear the query, so we don't strand them on an empty page.
  • All v5.1.0+ DX rules still hold: helpmesh.io host, opaque API keys, requestBody schemas, etc. The new samples reference the published @helpmesh-io/sdk@5.1.0.

noteVerified

  • 199 core + 258 web + 35 ai + 26 worker tests all pass. Typecheck + lint clean across all 15 packages.

v5.1.3

Cosmetic DX bundle: H8 + H2 + H5 + M1 + M2. Five visible polish items from the May 6 PM re-review (7.6/10 → expected ~8.0+) shipped as one bundle. No new features, no schema, no migration — pure cosmetic upgrades that compound the v5.1.0 + v5.1.1 + v5.1.2 trio's developer-trust signal.

fixFixed

  • H8 (`/docs/api` example timestamps) — replaced the literal 2024-12-15T10:30:00Z strings in the example response body with {{TS_CREATED}} / {{TS_UPDATED}} placeholders. New substituteDates() helper in the CodeBlock component swaps them for current ISO 8601 timestamps on each render (~3 hours ago for created, ~10 minutes ago for updated). Page can never go stale again. The "this product is dead" signal that's been carrying since at least May 2 is closed.
  • H2 (repo bare on GitHub) — added 4 root-level files matching the npm SDK's polish:
  • **H5 (X-RateLimit-* headers)** — setRateLimitHeaders() is now exported from the rate-limit module and called on every successful response in apiHandler.ts, not just 429 rejections. Every API response now carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset so integrators can throttle proactively (Stripe / GitHub / Twilio pattern). X-Request-Id was already there since v4.x.
  • M1 (404 CTA conditional)apps/web/src/app/not-found.tsx now reads the session: signed-in users get the existing "Go back to inbox" CTA; marketing visitors get a "Back to home" + "Browse docs" pair. The previous CTA assumed every visitor had an inbox, which broke for anyone landing via Google or a stale link.
  • M2 (footer community links) — added a new "Community" footer column in MarketingFooter.tsx with 4 real links: GitHub repo, SDK on npm, OpenAPI spec, support email. (No phantom Discord/X — we don't run those yet, and ghost-linking abandoned community surfaces is its own DX bug.) Footer grid bumped from 8 cols to 9 cols on lg.

- README.md — full product overview, SDK install, curl quickstart, repo layout, architecture rules, license, security contact. Pulls from the existing docs/developer/onboarding.md for local-dev steps. - LICENSE — MIT, matching the SDK. - SECURITY.md — vulnerability disclosure process, scope, 90-day coordinated-disclosure window, compliance roadmap (SOC 2 / ISO 27001 / 27701 / 42001), security@helpmesh.io contact. GitHub will surface this in the repo header automatically. - CONTRIBUTING.md — dev setup, quality gates, architecture rules, coding style, commit conventions, PR checklist. References the existing internal CLAUDE.md for full architecture context.

noteBehaviour notes

  • All v5.1.3 changes are visible-on-arrival: a developer typing the URL in a fresh browser session sees the new polish without any auth.
  • The 404 page check uses auth() in a try/catch; in edge contexts where auth isn't available we gracefully degrade to the marketing CTA (which is the safer default for stale-link traffic anyway).
  • The substituteDates() helper is <10 lines and only fires when the code block actually contains {{TS_*}} placeholders, so other code examples are untouched.

noteVerified

  • 199 core + 258 web + 35 ai + 26 worker tests all pass. Typecheck + lint clean across all 15 packages.

v5.1.2

DX criticals C1 + C4 closed. Two small but visible fixes from the May 6 re-review: a host callout on /docs/api so developers don't waste time on app.helpmesh.io, and a routing tweak so requesting the API on a non-canonical subdomain without auth surfaces a useful 401 HM-AUTH-MISSING instead of a misleading 404 HM-TENANT-NOT_FOUND.

fixFixed

  • C1 (host clarity) — added a blue info banner above the Auth banner on /docs/api calling out: API is at helpmesh.io, NOT app.helpmesh.io. The banner also links to the new @helpmesh-io/sdk npm package as the canonical client. Closes the residual confusion behind the prior C1 "false alarm" — the docs were always pointing at the right host, but a developer who *guesses* app.helpmesh.io (reasonable, given dashboards live at subdomains everywhere else) used to get a confusing HM-TENANT-NOT_FOUND with no path forward.
  • C4 (subdomain fall-through)apps/web/src/lib/api-handler.ts resolveTenant(): when a request hits a non-canonical subdomain (e.g. app.helpmesh.io/api/v1/threads) AND no Authorization header was sent, fall through to session/missing-auth resolution instead of returning tenant_not_found immediately. Result: app.helpmesh.io/api/v1/threads with no auth now returns 401 HM-AUTH-MISSING with the actionable hint "Provide an API key (Authorization: Bearer pk_…) or sign in." instead of 404 HM-TENANT-NOT_FOUND. If the caller *did* send an Authorization header (any kind — they explicitly addressed a tenant), we still surface tenant_not_found, since that's the more useful signal in that case.
  • 3 new pure-logic tests in apps/web/src/lib/__tests__/api-handler-subdomain-fallthrough.test.ts lock the new resolution logic so a future refactor can't silently re-introduce the bug.

noteBehaviour notes

  • This is a pure routing fix — no schema, no migration, no env. Just a smarter error code on the auth/tenant resolution edge case.
  • Real "I have a session for tenant A but typed tenant B's subdomain" scenarios still resolve correctly via session JWT in step 5 of resolveTenant(). Legitimate subdomain users are unaffected.
  • The existing api-handler-auth-codes.test.ts (the post-failure mapping test) still passes — this fix changes which failure reason fires, not how each reason maps to HTTP status / code.

noteVerified

  • 199 core + 258 web (+3) + 35 ai + 26 worker tests all pass. Typecheck + lint clean across all 15 packages.

v5.1.1

OpenAPI spec now generated from Zod schemas. Closes DX item #1 from the May 6 re-review (H6 in the original audit). Generated SDKs in any language now get real requestBody shapes for the highest-traffic endpoints, instead of falling back to body?: any. Also fixes L3 (the spec said bearerFormat: JWT even though our keys are opaque) and partially closes C1 (host clarity — see below for the rest).

featureAdded

  • `packages/core/src/openapi/registrations.ts` — single registration module that wires all top API routes through the existing (but previously-unused) @asteasolutions/zod-to-openapi registry. Imports the canonical Zod schemas (CreateThreadSchema from @helpmesh/core/thread, CreateMessageSchema from @helpmesh/core/message) and re-declares the route-local schemas (Reply, CreateWebhook, ScheduleMeeting) so the spec stays decoupled from apps/web.
  • 9 endpoints with full `requestBody` schemas: POST /threads, POST /threads/{id}/messages, POST /threads/{id}/reply, POST /threads/{id}/schedule-meeting, POST /webhooks, POST /webhooks/{id}/test, POST /threads/{id}/take-over, POST /threads/{id}/suppress-ai, DELETE /threads/{id}/suppress-ai. Includes per-field examples, response schemas, and proper status-code coverage (400 / 401 / 404 / 409 where relevant).
  • 23 endpoints registered total (the 9 above + 14 summary-only entries that preserve coverage of all paths the prior hand-written spec listed).
  • 11 new tests in packages/core/src/openapi/__tests__/registrations.test.ts — locks the spec shape so a regression doesn't silently strip requestBody schemas (which would re-introduce the H6 bug).

featureChanged

  • `apps/web/src/app/api/v1/openapi/route.ts` rewritten — was a 200-line hand-written spec, now a 20-line route that calls registerCoreApiRoutes() then generateSpec(). The hand-written spec is preserved in git history at commit f592cac for diff reference.
  • OpenAPI `info.version` bumped from 1.10.0 → 1.11.0 to mark the breaking change in spec shape (consumers who pin to >=1.11.0 will get the schemas; older pins will fall back to the legacy hand-written shape from CDN cache for 5 minutes).
  • OpenAPI `info.description` now carries the v5.1 "use helpmesh.io, not app.helpmesh.io" host callout that was implicit before. Closes the spec-side of C1.
  • `bearerFormat: 'JWT'` removed from BearerToken security scheme (L3 fix). The description now correctly says "Opaque API key (pk_... or sk_...). NOT a JWT — generate keys at /settings/api-keys."
  • `Cache-Control: public, max-age=300` added to the spec response — spec is static within a deploy.

noteBehaviour notes

  • The runtime validation in route handlers is unchanged. The route-level Zod schemas (in apps/web/src/app/api/v1/...) are still the source of truth for what the API actually accepts; the schemas in registrations.ts are documentation/code-generator input. If they diverge, the route handler wins at runtime.
  • This also closes a small DX bug we'd been carrying: @asteasolutions/zod-to-openapi was already a dependency in @helpmesh/core since some prior session, with a half-built registry, but no caller was wiring it up. Now it does the actual work.

noteVerified

  • 199 core (added 11) + 255 web + 35 ai + 26 worker tests all pass. Typecheck + lint clean across all 15 packages.

v5.1.0

v5.1.0 General Availability — AI cost guardrails + i18n deferral copy. The full alpha train (alpha.1 → alpha.2) drops the prerelease tag with no additional code changes. Both alphas verified live in production with all gates clean. Tenants can now opt into a monthly USD budget for AI spend (auto-pauses at 100%) and customers in supported locales receive deterministically-translated deferral copy when AI defers to a human.

noteWhat's in v5.1.0 (cumulative since v5.0.0)

  • Tenant AI budget alerts (alpha.1) — opt-in aiMonthlyBudgetUsd ceiling, email at 80% MTD, email + auto-pause every inbox at 100%. Hourly worker, idempotent per-period dedupe, audit trail. No DB migration. See alpha.1 entry below for full surface.
  • Per-language deferral templates (alpha.2) — pre-translated AI deferral copy for en, ar, fr, es, de, zh, embedded verbatim in the auto-reply prompt. Replaces the previous "translate at runtime" instruction. Regional variants fall back to base language; unknown locales fall back to English. See alpha.2 entry for the resolver and the maintenance contract.

noteVerified at GA

  • 35 AI + 188 core + 26 worker + 255 web + 22 db + others tests all pass. Typecheck + lint + build clean across all 15 packages.
  • v5.1.0-alpha.1 live in production since 2026-05-05, smoke-test 200 OK.
  • v5.1.0-alpha.2 live in production since 2026-05-06, smoke-test 200 OK.

notePublic DX status at GA cut

Re-audited 2026-05-06: 6.1/10 (up from 3.3/10 on May 2). Three of five May-2 criticals fully resolved (/docs 404s, three-thread-shapes confusion, stale changelog), one mostly resolved (error-code routing), and the fifth was a false alarm in the prior audit. Full re-review at docs/devex-review-2026-05-06.md. Highest-impact remaining work: publish @helpmesh/sdk to npm, add OpenAPI requestBody schemas, add language tabs to /docs/api.

noteWhy "5.1" (and not 5.0.1)

v5.1 ships meaningful new functionality on top of v5.0 GA: a new tenant-facing setting (monthly USD budget) with first-class UI, a new email pattern (budget alerts), and a behavioural change (auto-pause). Per-language deferral templates is itself a customer-visible quality improvement, not a bug fix. Both warrant a minor bump.

v5.1.0-alpha.2

Per-language deferral templates. Picks up the second item from v5.0's "what's NOT shipped" list: the AI auto-reply prompt no longer asks Claude to translate the deferral fallback at runtime. Each supported locale now has a pre-translated, reviewable template embedded in the prompt verbatim. Replaces the previous "translate to {locale} if needed" instruction, which was prone to drift and the occasional English fragment.

featureAdded

  • `packages/ai/src/prompts/deferral-templates.ts` — pre-translated deferral copy for en, ar, fr, es, de, zh. Single canonical message per locale, written to match the English original semantically (thanks → can't confirm from help center → teammate will follow up).
  • `pickDeferralTemplate(locale)` — pure resolver that handles BCP 47 codes (en-GB, fr_CA → base language), uppercase input (DEde), unknown locales (fall back to English), and undefined/empty input.
  • `SUPPORTED_DEFERRAL_LOCALES` + `DeferralLocale` type — exported so callers/tests can assert against the supported set without re-listing it.
  • 24 new tests — 11 in deferral-templates.test.ts (resolver + per-locale content + drift guard) and 13 updated/new in auto-reply-from-kb-locale.test.ts (pinning the *embedded template* per locale rather than the old "translated to … if needed" string).
  • `packages/ai/vitest.config.ts` — gives the AI package a working pnpm test script that resolves the include glob from the package directory rather than the monorepo root. Pre-existing condition fixed in passing.

featureChanged

  • `buildAutoReplyFromKbPrompt` — rule #2 now embeds the locale-correct deferral template inline and tells Claude "use this exact deferral message verbatim — do not rewrite or translate it." No call-site changes; the orchestrator already passes customer.locale through.

noteBehaviour notes

  • Customers with an ar locale now see the Arabic deferral copy whenever the AI defers to a human, even if Claude would have produced something slightly different at runtime.
  • Languages outside the supported set still get the English deferral template (zero regression vs v5.1.0-alpha.1, which used English with a translation hint).
  • Regional variants resolve to their base language (en-GBen, fr-CAfr). Tenants needing a regional variant can submit it as a supported locale.
  • Maintenance contract documented in the templates file: any change to the English copy must be propagated to all five other languages to prevent drift.

noteVerified

  • 35 AI + 188 core + 26 worker + 255 web tests pass (504 total). Typecheck + lint clean across all 15 packages.

v5.1.0-alpha.1

Tenant AI budget alerts. Picks up the v5.0 "what's NOT shipped" list: tenants can now set a monthly USD ceiling on AI spend and get warned at 80% MTD, then auto-paused at 100%. Opt-in — existing tenants see no behaviour change until they configure a budget.

featureAdded

  • `Tenant.settings.aiMonthlyBudgetUsd` — opt-in monthly USD cap (0–100k, null = no cap). Lives in the existing settings JSON column, no migration.
  • `Tenant.settings.aiBudgetAlertedAt`{ period, level, sentAt } dedupe record so the worker only emails once per threshold per month.
  • Settings → AI · Spending limits card — new editor with daily token limit (was previously API-only) and monthly USD budget. "Remove cap" button clears the budget + dedupe state in one shot.
  • `PATCH /api/v1/ai` — accepts aiMonthlyBudgetUsd: number | null. Passing null or 0 clears the cap and the alert dedupe state.
  • `GET /api/v1/ai` — returns the configured budget alongside the daily token limit.
  • `GET /api/v1/usage`aiUsage block now includes monthlyBudgetUsd and monthlyBudgetPercent (null when no cap).
  • Billing · AI usage card — second progress bar for "MTD against monthly budget" (violet → amber at 80% → red at 100%) with a deep-link to Settings → AI when the cap is hit.
  • `apps/worker/src/processors/ai-budget-check.ts` — hourly cron on the low queue. Sweeps ACTIVE and TRIAL tenants with aiMonthlyBudgetUsd > 0, computes MTD spend via the shared estimator, and dispatches the appropriate threshold email.
  • `EmailSender.sendAiBudgetAlert` — two variants (warning at 80%, exceeded at 100%) sharing the same template envelope as the existing transactional alerts (dunning, webhook-disabled). Tag: ai-budget-warning or ai-budget-exceeded.
  • Auto-pause at 100%prisma.inbox.updateMany({ aiFallbackEnabled: false }) flips every inbox in the tenant. The tenant raises the budget OR re-enables AI per inbox to resume. Inboxes are NOT auto-re-enabled at month rollover — the tenant decides.
  • Audit trailai.budget.warning (80%) and ai.budget.exceeded.auto_pause (100%, with inbox-pause count) entries in AuditLog.
  • `@helpmesh/core/billing/ai-cost` — promoted estimateAiCostUsd from apps/web into @helpmesh/core so the worker shares the exact pricing constants. The web cost-helpers.ts re-exports under the original name; no caller changes needed.
  • 19 new tests — 6 in packages/core/src/__tests__/ai-cost.test.ts (pricing pin), 13 in apps/worker/src/processors/ai-budget-check.test.ts (readAlertedAt parsing + decideAlertLevel covering same-period dedupe, 80→100 escalation, month-boundary reset).

noteBehaviour notes

  • Default for every existing tenant: aiMonthlyBudgetUsd is unset → no alerts, no auto-pause. Zero behaviour change without explicit opt-in.
  • New month auto-resets alert state because the period field stops matching currentUsagePeriod().
  • The 100% email is the *first* time a tenant learns AI was paused; the 80% email arrives in time to raise the budget if they want.
  • Daily token limit (existing) and monthly USD budget (new) are independent ceilings — either can pause AI for its respective window.

noteVerified

  • 188 core + 26 worker + 255 web tests pass. Typecheck + lint clean across all 15 packages.

v5.0.0

General Availability of Working Hours v5.0. The full feature train (alpha.1 → alpha.5 → rc.1) drops the prerelease tag with one final addition: the AI usage panel in Settings → Billing, so tenants can see the spend they're authorising when they flip aiFallbackEnabled=true.

noteAdded (the final piece)

  • AI usage panel on /settings/billing — month-to-date tokens, estimated cost (USD, blended Sonnet/Haiku pricing), today's burn, daily-limit progress bar, 30-day token sparkline. Renders only when usage.aiUsage is present in the API response (graceful for old tenants pre-aggregated rows).
  • `/api/v1/usage` extension — response now includes an aiUsage block with mtdTokensIn/Out/Total, mtdEstimatedCostUsd, todayTokensIn/Out/Total, dailyLimit, dailyRemaining, dailyAllowed, and a last30Days series for the sparkline.
  • `packages/.../usage/cost-helpers.ts` — pure module with estimateCostUsd, todayPeriod, last30DayPeriods so the unit test can exercise the math without dragging in the route's auth + middleware imports. 7 tests pinning the cost calculation.

notev5.0.0 — what's in the release (cumulative since v4.x)

  • Phase 1 + 2 (alpha.1): Inbox.businessHours, Inbox.aiFallbackEnabled, Inbox.aiResponseTargetMinutes columns. Settings UI for per-day hours + holidays + out-of-hours auto-reply message + AI fallback opt-in. isInsideBusinessHours helper.
  • Phase 3 (alpha.2): apps/worker/src/processors/ai-auto-reply.ts — KB-grounded, business-hours-aware, race-safe Claude auto-reply worker. Thread.aiHandoffAt column. Strict KB prompt with deferral template + 0.7 confidence floor. Outbox dispatcher hook on message.created.
  • Phase 4 (alpha.3): BOT message badge in MessageBubble, POST /v1/threads/:id/take-over endpoint with audit trail + outbox event, "AI replied — take over" banner in inbox header, AI Handoff Rate stat card on Reports → Overview.
  • Phase 4b (alpha.5): per-thread AI suppression (Thread.aiSuppressedAt + POST/DELETE /v1/threads/:id/suppress-ai), BOT bubble audit footer (cited articles + confidence), webhook event group for thread.ai_handoff_cleared, public docs sweep.
  • Multi-language smoke tests (rc.1): 13 tests covering Arabic, French, Spanish, German, Mandarin, regional variants. /docs/api Threads group sweep.
  • Cost panel (this release): visibility into the AI spend the feature authorises.

The GA tag bundles everything from the alpha train:

noteNotes

  • 255 web + 13 worker + 185 db + 22 AI prompt tests pass. Typecheck + lint + build clean across all 15 packages.
  • No schema, no migration, no env. Pure code on top of alpha.5's columns.
  • Default behaviour for existing tenants is ZERO changeInbox.aiFallbackEnabled is false everywhere. Tenants must explicitly opt in per inbox in Settings → Inboxes → Hours.

noteWhy "5.0"

v5 marks the first release where HelpMesh actively replies to customers without an agent in the loop. That's a meaningful step beyond "we route human work efficiently" (the v4 surface) — and warrants the major-version bump regardless of whether downstream API consumers are affected (they aren't).

noteWhat's NOT in v5.0.0 (for the next minor)

  • True output-quality verification of multi-language replies (the rc.1 tests pin the prompt; not Claude's actual output across languages).
  • Per-language deferral templates (currently English with "translate if needed" instruction).
  • Tenant-level cost alerts at 80%/100% of monthly budget.
  • AI usage panel embedded into Reports (currently only in Billing).

v5.0.0-rc.1

Release candidate. Two pieces of polish before dropping the alpha tag: multi-language smoke tests pinning the auto-reply prompt's locale handling, and a sweep of the live /docs/api page covering all per-thread action endpoints that were previously markdown-only.

featureAdded

  • 13 new locale tests in packages/ai/src/prompts/auto-reply-from-kb-locale.test.ts — exercise the auto-reply prompt with Arabic (RTL), French, Spanish, German, Mandarin (CJK), regional en-GB, no-locale fallback, Arabic + CJK customer names, full Arabic conversation transcript, and locale-aware sign-off behaviour. Confirms the prompt threads the customer's locale into both rule #2 (deferral translation) and rule #4 (reply language) without mangling non-Latin content.
  • 11 endpoints added to `/docs/api` under the Threads group, covering action paths that were live but previously undocumented in the live page: POST /threads/:id/reply, POST /threads/:id/assign, POST/DELETE /threads/:id/snooze, POST /threads/:id/merge, GET/POST /threads/:id/labels, POST /threads/:id/take-over, POST/DELETE /threads/:id/suppress-ai, POST /threads/batch. Each row notes the version that introduced it.

noteNotes

  • 248 web + 13 worker + 185 db + 22 AI prompt tests pass. Typecheck + lint + build clean.
  • No schema, no migration, no env changes.
  • Working Hours v5 alpha-feature surface is now complete (alpha.1 → alpha.5). This rc is the cooldown — if smoke testing through to v5.0.0 surfaces nothing critical, drop the prerelease tag.

noteWhat's left for v5.0.0

  • Cost/usage panel (Settings → Billing → AI usage). Tenants currently can't see their AI spend — CostTracker.checkBudget gates each call but the budget is invisible. ~3-4hr; deferred to its own ship.
  • Live in-prod multi-language verification (these tests pin the *prompt*, not Claude's output quality across languages — that needs real customer messages).

v5.0.0-alpha.5

Working Hours v5.0 — beta polish. Three improvements that close the gap between alpha and beta: agents can now (a) audit AI replies in the inbox bubble — confidence + cited article count surface inline, (b) disable AI on a single thread without changing the inbox-wide setting, and (c) discover the new AI webhook events in the picker UI.

featureAdded

  • `Thread.aiSuppressedAt` (DateTime?) — agent-controlled per-thread escape hatch. When set, the auto-reply worker skips this thread regardless of Inbox.aiFallbackEnabled. Null = follow the inbox default.
  • `Message.aiCitedArticleIds` (String[]), `Message.aiConfidence` (Float?), `Message.aiDeferredToHuman` (Boolean default false) — AI auto-reply audit metadata persisted on the BOT message row. NULL on all human / system / inbound messages. The worker now writes these alongside the existing OutboxEvent payload.
  • `POST /api/v1/threads/:id/suppress-ai` + `DELETE /api/v1/threads/:id/suppress-ai` — toggle per-thread suppression. Idempotent. AGENT role required. Posts a SYSTEM message + writes audit row on each toggle.
  • MessageBubble audit footer — for BOT TEXT messages, the bubble now shows "Cited N KB articles · Confidence X%" (or "Deferred to human" when the worker hit no-KB-match path).
  • "Disable AI for this thread" button alongside the existing "AI replied — take over" banner. Click → POST /suppress-ai → orange "AI disabled — re-enable" banner replaces it.
  • AI webhook event groupWEBHOOK_EVENT_CATALOG now exposes a "AI" section with thread.ai_handoff_cleared. The existing message.created description notes the BOT-payload extension fields.
  • Public docs — new "AI" section in docs/api/reference.md covers take-over, suppress-ai (POST + DELETE), and the BOT-message extra fields.

noteSchema

  • 20260505100000_ai_message_meta_and_thread_suppress — adds Thread.aiSuppressedAt (nullable) + 3 columns on Message (defaulted). Additive only.

noteNotes

  • 248 web tests pass (+8 new for suppress-ai). 13 worker + 185 db unchanged. Typecheck + lint + build clean across all 15 packages.
  • Migration must run before this image rolls — worker references Message.aiCitedArticleIds etc., suppress-ai endpoint references Thread.aiSuppressedAt.
  • This rounds out the "v5.0 alpha → beta" feature surface. Next slice is multi-language reply quality testing + cost/usage panel.

v5.0.0-alpha.4

Meeting PATCH: opt-in subject override. When an agent renames a meeting, the rescheduled invite email currently keeps the original thread subject so Gmail keeps the conversation grouped. That's the right default but means the customer's inbox still shows the OLD title even after a rename. New optional useTitleAsSubject body field flips that behaviour per-call, useful when the title materially changed and the customer should see it in their inbox.

featureAdded

  • `useTitleAsSubject?: boolean` field on PATCH /api/v1/threads/:id/scheduled-meetings/:mid. Default false (preserves Gmail threading by reusing the thread subject). When true, the rescheduled invite email's Subject becomes the new meeting title.
  • 8 new tests in meeting-patch-subject.test.ts — covers schema accept/reject, refine() interaction with the new optional field, and subject-resolution logic for the three states (true / false / omitted).

featureChanged

  • `/docs/api` Meetings group + `docs/api/reference.md` updated to document the new field and the threading caveat.

noteNotes

  • 240 web tests pass (+8). Typecheck + lint + build clean.
  • No schema, no migration, no env. Pure code.
  • Surfaced during PropVista's smoke test of v4.9.14 — they renamed a meeting and the email arrived with the old subject. We documented the threading-vs-clarity tradeoff at the time and deferred the flag; this is that follow-up.

v5.0.0-alpha.3

Working Hours v5.0 — Phase 4. Closes the loop on the AI auto-reply feature with the visible UX surfaces. BOT messages now render with a distinctive "AI replied" badge in the inbox timeline; threads with an active AI handoff show a "AI replied — take over" banner the agent clicks to resume manual control; reports overview gains an "AI Handoff Rate" stat card showing what % of threads in the last 30 days had AI involvement.

featureAdded

  • `MessageBubble` v5 badgeBOT-authored TEXT messages render with a violet "AI replied" pill and "AI assistant" author label. Distinct from the existing AUTO_REPLY "AI draft" path which is for in-product agent assistance.
  • `POST /api/v1/threads/:id/take-over` — clears Thread.aiHandoffAt, posts a SYSTEM message ("Sarah took over from AI"), writes an audit row with action=thread.ai_handoff_cleared, fires an outbox event for webhooks/realtime. Idempotent — calling on a thread without an active handoff returns 200 with alreadyHandled: true. AGENT role required.
  • "AI replied — take over" banner in the thread header (next to the snooze badge) — only renders when aiHandoffAt is set. One click → calls the take-over endpoint → toast confirmation → handoff cleared.
  • AI Handoff Rate stat card on /reports Overview tab. Shows the percentage of threads created in the last 30 days that had at least one AI auto-reply fire, plus the raw count. Reports overview grid widened from 4 to 5 columns.
  • `/api/v1/analytics` extension — response now includes aiHandoffCount (threads with aiHandoffAt >= 30 days ago), aiHandoffRate (% of windowed threads, null when window is empty), aiHandoffWindowDays (currently always 30, exposed so future tunability is API-visible).

featureChanged

  • Thread detail GET already returned aiHandoffAt (added in alpha.2 schema). The agent inbox ThreadDetail TS interface now declares it explicitly so the take-over banner has a typed source.

noteNotes

  • 232 web tests pass (+6 from the new take-over test file). Typecheck + lint + build clean.
  • No schema changes, no migration. Pure code release on top of alpha.2's Thread.aiHandoffAt column.
  • Webhooks subscribed to thread.ai_handoff_cleared will start receiving deliveries when this rolls. Existing tenants who don't subscribe to that event are unaffected.

noteWhy now

alpha.2 made the AI worker fire customer replies but provided no agent visibility — a tenant who flipped aiFallbackEnabled=true would only see BOT messages as anonymous "System" entries with no way to take control. alpha.3 closes that gap so the feature is actually usable end-to-end.

v5.0.0-alpha.2

Working Hours v5.0 — Phase 3. The auto-reply worker that turns alpha.1's configured data into actual customer-facing AI replies. Customer messages on inboxes with aiFallbackEnabled=true now trigger an evaluation: outside business hours fires immediately; inside hours defers by aiResponseTargetMinutes and fires only if no human responded in that window. Replies are grounded ONLY in the tenant's published KB articles (semantic search via pgvector), and low-confidence drafts are held back for human review rather than sent.

featureAdded

  • `apps/worker/src/processors/ai-auto-reply.ts` — new BullMQ processor on the standard queue. Decides fire-now vs defer vs skip based on inbox config + business hours, runs KB semantic search, calls the new orchestrator method, persists Message with authorType=BOT, and sends via the existing Postmark reply pipeline. Sets Thread.aiHandoffAt so reports can distinguish AI-touched threads.
  • `AiOrchestrator.autoReplyFromKb()` — new method in @helpmesh/ai. Strict KB-grounded prompt; returns { draft, citedArticleIds, confidence, deferredToHuman } or null. Uses temperature 0.2 for consistency.
  • `buildAutoReplyFromKbPrompt()` — new prompt builder. Hard-constrained system prompt with explicit deferral template ("a member of our team will follow up..."), 150-word cap, plain-text only, locale-matching. 9 tests.
  • `Thread.aiHandoffAt` (DateTime?) — set when the worker fires a BOT message; cleared by the agent's "Take over" action (Phase 4 UI).
  • Outbox hook: message.created events with authorType=CUSTOMER enqueue an ai-auto-reply job. Idempotent via jobId: ai-auto-reply:{messageId}.

noteSchema

  • 20260505000000_thread_ai_handoff_at — adds Thread.aiHandoffAt (nullable). Additive only.

noteMitigations

  • Hallucination: prompt explicitly forbids invented policies/prices/deadlines; model must defer to a human when KB doesn't cover the question. Confidence floor of 0.7 — anything lower is held for a human.
  • Race: worker re-fetches the thread on wake and skips if any AGENT or BOT message arrived after the trigger.
  • Off-channel: only auto-replies on EMAIL / API / WIDGET inboxes. SLACK/SMS/WhatsApp need provider-specific echo flows that are out of scope here.
  • Missing AI key: when ANTHROPIC_API_KEY is unset, the worker logs and exits cleanly. No half-replies.
  • No KB matches: worker short-circuits to the deferral template ("a member of our team will follow up...") instead of inventing an answer.
  • Cost runaway: orchestrator's existing CostTracker.checkBudget gates each call. Auto-reply respects the same daily/monthly tenant budget as suggestReply and triage.

noteNotes

  • 226 web + 9 (auto-reply prompt) + 9 (business-hours) tests pass. Typecheck + lint + build clean across all 15 packages.
  • Migration must run before this image rolls — the worker references Thread.aiHandoffAt.
  • Still alpha. Phase 4 (in-app BOT message badge + "Take over" button + reports tile) is the next slice.

noteWhy now

alpha.1 shipped the schema + UI on May 5 morning. Tenants can configure hours, but nothing happens with that data yet. alpha.2 wires the actual behaviour — the moment a tenant flips aiFallbackEnabled=true, customer-facing AI replies start firing. Shipping behind opt-in (default false) keeps blast radius small.

v5.0.0-alpha.1

Working Hours v5.0 — Phase 1 + Phase 2. First slice of the on-shift / AI-fallback feature: per-inbox business-hours configuration with a settings UI. The columns are settable and the helper exists, but the actual auto-reply worker doesn't ship until Phase 3 (next release). This release is intentionally an alpha — tenants can configure hours and toggle the "AI fallback" opt-in, but no behaviour change in the inbound-message path yet.

featureAdded

  • `Inbox.businessHours` (Json?, nullable) — per-inbox override for the existing tenant-level Tenant.businessHours. When NULL, the inbox falls back to tenant default.
  • `Inbox.aiFallbackEnabled` (Boolean, default false) — opt-in flag for the v5.0 Phase 3 AI auto-reply worker. False on all existing rows so no tenant accidentally starts auto-replying.
  • `Inbox.aiResponseTargetMinutes` (Int, default 30) — minutes to wait inside business hours before AI fires.
  • `BusinessHoursSchema` extension: optional holidays: [{date: 'YYYY-MM-DD', label?: string}] and optional outOfHoursMessage (sanitized HTML, max 5000 chars). Backwards-compatible — existing tenant rows pass validation unchanged.
  • `isInsideBusinessHours(hours, when?)` helper in @helpmesh/core — timezone-aware, holiday-aware. 9 unit tests covering window edges, weekday-no-schedule, UAE Sun-Thu workweek, holiday match (with timezone-correctness), empty holidays.
  • Settings UI at /settings/inboxes/[id]/business-hours — checkbox to enable per-inbox override, weekday open/close pickers, holiday list (add/remove/label), out-of-hours auto-reply textarea, AI fallback opt-in with response-window picker.
  • "Hours" link on each row of /settings/inboxes so tenants can navigate to the editor.

featureChanged

  • `PATCH /api/v1/inboxes/:id` accepts businessHours (BusinessHoursSchema or null), aiFallbackEnabled (boolean), aiResponseTargetMinutes (int 1-1440). PATCH handler refactored to use a single Prisma.InboxUpdateInput builder so the team-relation handling reads cleanly with the new optional fields.
  • `computeSlaDeadline` internal getDaySchedule() helper now uses a strict DayKey type instead of keyof Omit<BusinessHours, 'timezone'> so the new optional holidays + outOfHoursMessage fields don't confuse the day-of-week lookup. No behaviour change.

noteSchema

  • 20260504200000_inbox_business_hours_and_ai_fallback — additive only (3 columns, all nullable / safely defaulted). Zero behaviour change at deploy time.

noteNotes

  • 226 web + 9 new core (business-hours) tests pass. Typecheck + lint + build clean across all 15 packages.
  • Migration must run before this image rolls — see runbooks. The new columns are referenced by PATCH /api/v1/inboxes/:id so the route 500s if the schema doesn't have them yet.
  • This is an alpha — Phase 3 (the worker that actually fires auto-replies) lands in v5.0.0-alpha.2.

noteWhy now

4 ships on May 4 closed out the Zoom train; Working Hours is the next big feature. Splitting into 5 phases (this is 1+2) keeps blast radius small. Phase 1+2 alone is shippable because tenants can start populating their hours config now — when Phase 3 arrives, the data is already there to drive it.

v4.9.16

Public API documentation catches up with what shipped over the last week. The live /docs/api page gains three full groups — Inboxes, Meetings, Integrations — that were previously absent despite being in production since v4.9.6 (Inbox.isDefault), v4.9.0 (meetings), and v4.9.12 (Zoom). The reference markdown (docs/api/reference.md) gets the same updates so the two stay in sync. No code changes — pure DX.

featureAdded

  • Inboxes group on /docs/api — covers GET /inboxes, POST /inboxes, PATCH /inboxes/:id (with isDefault flag), DELETE /inboxes/:id. Includes a worked response example showing the isDefault: true shape integrators introspect.
  • Meetings group on /docs/api — covers schedule / list / edit / cancel + attendance + recording endpoints. Notes the Google Meet vs Zoom dispatch behaviour and the optional provider parameter (v4.9.13).
  • Integrations group on /docs/api — covers Google Meet OAuth (status / connect / disconnect) and Zoom OAuth (GET / connect / DELETE). Notes that the Zoom GET response includes scopes so integrators can detect tenants connected before v4.9.15 who need to reconnect.

featureChanged

  • `docs/api/reference.md`POST /threads row now documents inboxId as optional with the isDefault → oldest-eligible fallback chain. Meetings section is provider-agnostic ("Meetings" not "Meetings (Google Meet)") and notes Zoom edit/cancel parity. New "Integrations — Zoom" section mirrors the Google one.

noteNotes

  • 226 web + 27 channels tests pass (no test changes — docs only). Typecheck + lint + build clean.
  • No schema, no env, no migration. Web image rolls on the existing task definition.
  • Closes the doc gap surfaced indirectly during the v4.9.13–v4.9.15 ship train: integrators who hit /docs/api couldn't see meetings or Zoom existed.

v4.9.15

Two fixes shipped together: agent/tenant signatures now appear in Zoom + Google meeting invite, reschedule, and cancel emails (closing a polish gap surfaced during v4.9.13's smoke test); and the Zoom granular-scopes set has been corrected to include meeting:update:meeting + meeting:delete:meeting so reschedule and cancel actually succeed (v4.9.14 shipped the dispatcher refactor but Zoom rejected PATCH + DELETE with code 4711 because we'd only requested meeting:write:meeting, which Zoom split into separate verbs mid-2024). Tenants who connected Zoom before v4.9.15 must disconnect and reconnect to grant the new scopes — pre-flight check returns a clean HM-ZOOM-MISSING_SCOPE 409 with that exact instruction.

featureAdded

  • `signatureHtml` field on `MeetingInviteTemplateInput` and `MeetingCancelTemplateInput`. Pre-sanitized HTML (the settings UI runs DOMPurify at write time) is rendered below the meeting body, above the footer. Falls back to a {agentName}\n{tenantName} block when null. Plain-text fallback strips tags. 3 new tests in meeting-invite-template.test.ts (channels: 27 tests, was 24).
  • `hasZoomUpdateScope` + `hasZoomDeleteScope` helpers in apps/web/src/server/zoom/token-store.ts, mirroring Google's hasCalendarScope. PATCH and DELETE pre-flight against the granted scopes and return HM-ZOOM-MISSING_SCOPE (409) when missing — clean reconnect prompt instead of a raw 502.

featureChanged

  • `POST /api/v1/threads/:id/schedule-meeting` fetches agentUser.replySignature + tenant.replySignature (same resolution order as /threads/:id/reply) and passes the chosen signature into both the persisted in-thread message and the Postmark template payload.
  • `PATCH/DELETE /api/v1/threads/:id/scheduled-meetings/:mid` same treatment — reschedule + cancel emails now carry the agent's branded signature.
  • `ZOOM_SCOPES` in packages/channels/src/zoom/oauth-client.ts now includes meeting:write:meeting, meeting:update:meeting, meeting:delete:meeting, meeting:read:meeting, and user:read:user (was 3 scopes). The Zoom Marketplace app config must be updated to surface these on the consent screen — see runbook.

noteManual ops

  • Zoom Marketplace app: add meeting:update:meeting + meeting:delete:meeting to the OAuth → Scopes section of the HelpMesh Meetings app in marketplace.zoom.us. The granular-scope checkboxes are under Meeting → Update meetings (single) and Meeting → Delete meetings (single).
  • Existing tenants: any tenant that connected Zoom before this release sees a clean HM-ZOOM-MISSING_SCOPE 409 on PATCH/DELETE. The error copy tells them to disconnect + reconnect, which re-runs the consent screen with the new scopes.

noteWhy now

PropVista's first end-to-end Zoom smoke test (against v4.9.14) cancelled correctly via the dispatcher path — but Zoom's API returned 400 code 4711 ("Invalid access token, does not contain scopes:[meeting:delete:meeting:admin, meeting:delete:meeting]"). The old comment in oauth-client.ts claimed meeting:write:meeting covered create+update+delete; that was true before Zoom's mid-2024 granular-scopes split and we missed updating it. Same smoke test surfaced the missing signature on the Zoom invite email, which is why both fixes ship together.

noteNotes

  • 226 web + 27 channels tests pass. Typecheck + lint + build clean across all 15 packages.
  • No schema change. No env change. Web image rolls on the existing task definition.

v4.9.14

Zoom edit/cancel parity. PATCH and DELETE on /api/v1/threads/:id/scheduled-meetings/:mid now dispatch by meeting.provider instead of hardcoding Google, completing the Zoom integration so Zoom-scheduled meetings can be rescheduled or cancelled the same way Google ones can. The reschedule + cancel email re-renders use the correct provider name and brand color. No schema change — provider and upstreamId already landed in v4.9.13.

featureChanged

  • `PATCH /api/v1/threads/:id/scheduled-meetings/:mid` dispatches via the video provider returned by getProviderForTenant(tenantId, meeting.provider). Drops the hardcoded googleMeet.patchMeetEvent call, the integration scope check, and the isGoogleMeetEnabled() gate (the dispatcher handles all three). Reads meeting.upstreamId ?? meeting.calendarEventId for backwards compatibility with rows created before v4.9.13.
  • `DELETE /api/v1/threads/:id/scheduled-meetings/:mid` same treatment — dispatches through the provider's .delete() method.
  • Reschedule + cancel emails now pass providerName (e.g. "Zoom meeting" / "Google Meet meeting") and the matching brand color into the templates, matching the v4.9.13 invite-email behaviour.
  • New shared helpers at the top of the route: resolveProviderOrError() translates ProviderNotConnectedError to a clean 409, and handleProviderError() maps GoogleMeetNotConnectedError, ZoomNotConnectedError, and CircuitOpenError into provider-aware error codes.

featureAdded

  • `HM-ZOOM-MEETING_PATCH_FAILED` + `HM-ZOOM-MEETING_DELETE_FAILED` + `HM-MEETING-NO_UPSTREAM_ID` error codes for tenant-clear messaging when the new dispatch path encounters a Zoom-side or data-integrity failure.

noteNotes

  • 226 web tests pass (unchanged). Typecheck + lint + build clean across all 15 packages.
  • No migration. No env change. Web image rolls on the existing task definition (helpmesh-prod-web:26).
  • Closes the Zoom integration loop that v4.9.12 + v4.9.13 started.

v4.9.13

Zoom integration Commit 2: schedule-meeting now dispatches by provider. Zoom-only tenants and Zoom+Google tenants can both schedule meetings from inside a thread; the modal asks which provider when both are connected (Google default), the email invite says "Zoom meeting" / "Google Meet" appropriately, and the upstream meeting is created via whichever provider was picked. Editing and cancelling a Zoom meeting still go through Google's PATCH/DELETE routes — that's deferred to v4.9.14 to keep this commit's blast radius bounded. Schedule + view + email + timeline card all work end-to-end for both providers in this release.

featureAdded

  • Video provider dispatcher (apps/web/src/server/video-meeting/provider.ts). Pure function returning a VideoMeetingProvider discriminated record with .create / .patch / .delete. Auto-picks Google when both connected (per the feedback_video_provider_default decision); single connected provider auto-wins; throws NoProviderConnectedError when neither.
  • `ScheduledMeeting.provider` column with backing VideoProvider enum (GOOGLE_MEET | ZOOM). Defaults to GOOGLE_MEET so existing rows backfill safely. New upstreamId column holds the provider-side id (Calendar event id for Google, numeric meeting id stringified for Zoom).
  • Provider toggle in ScheduleMeetingModal — only renders when both providers are connected. Default selection is Google. Switches between providers per-meeting; not persisted as a tenant preference. Title font, success card, and join-link copy all update with the picked provider.
  • Provider-aware email brandingmeeting-invite-template.ts and meeting-cancel-template.ts accept a providerName field. Headers, body copy, "Open in {provider}" secondary link, and plain-text fallbacks all reflect the picked provider. Zoom invites use Zoom's brand color (#2D8CFF); Google keeps the existing accent.
  • `HM-VIDEO-NO_PROVIDER_CONNECTED` + `HM-ZOOM-NOT_CONNECTED` + `HM-ZOOM-MEETING_FAILED` + `HM-ZOOM-TEMPORARILY_UNAVAILABLE` error codes on /threads/:id/schedule-meeting for tenant-clear messaging.

featureChanged

  • `POST /api/v1/threads/:id/schedule-meeting` accepts an optional provider: 'GOOGLE_MEET' | 'ZOOM' field. Omitted defaults to Google when both connected, single connected provider, or 409 when neither.
  • `ScheduleMeetingModal` now fetches both /integrations/google and /integrations/zoom on open, picks default provider, and surfaces a "Use Zoom instead" toggle when both are connected. Title bar color and "Schedule {provider}" button label update with the active selection.
  • Composer button title changed from "Schedule a Google Meet…" to provider-agnostic copy.
  • `MeetingMessageBubble` accepts an optional provider field on the meeting prop (rendered surface unchanged in this release; v4.9.14 wires the provider icon + label).

noteSchema

  • 20260503120000_scheduled_meeting_provider — adds ScheduledMeeting.provider (NOT NULL DEFAULT 'GOOGLE_MEET') and ScheduledMeeting.upstreamId (nullable). Backfill copies existing calendarEventId into upstreamId so the new dispatcher can read a single field. Purely additive.

noteNotes

  • Edit/cancel for Zoom meetings is NOT in this release. Zoom meetings can be created end-to-end; editing or cancelling them still routes through the Google-only PATCH/DELETE handlers and will fail. v4.9.14 ships the dispatcher refactor for those routes — limited scope, low risk follow-up.
  • 226 web tests pass (unchanged from v4.9.12). Typecheck + lint + build clean across all 15 packages.
  • Migration applied to prod RDS via ECS one-shot before this image rolls.

noteWhy now

v4.9.12 shipped the Zoom OAuth flow but the agent UI still hardcoded Google in the modal — a tenant who connected Zoom couldn't actually use it. This release closes that loop. Editing/cancelling Zoom-scheduled meetings is a follow-up because it involves rewriting two long routes and risks breaking the Google flow that already works in prod.

v4.9.12

First slice of Zoom integration: OAuth connect + disconnect only, no meeting creation yet. Settings → Integrations now shows a Zoom card next to the Google Meet card; clicking Connect runs a real OAuth round-trip and persists encrypted tokens to a new ZoomIntegration row. Meeting create/edit/cancel still go through Google Meet — that's Commit 2 of the Zoom rollout, deferred until this slice is verified end-to-end against the live Zoom Marketplace app. Shipping in slices because the v4.9.7→v4.9.8 churn pattern taught us each unproven external integration deserves its own roll.

featureAdded

  • Zoom OAuth client (packages/channels/src/zoom/oauth-client.ts). REST against zoom.us/oauth + api.zoom.us/v2 — no SDK. Mirrors the shape of google-meet/oauth-client.ts: buildAuthorizationUrl / exchangeCodeForTokens / refreshAccessToken / revokeToken / fetchUserInfo / createZoomMeeting / patchZoomMeeting / deleteZoomMeeting. The meeting CRUD methods are exported but not yet wired — they're the foundation for Commit 2. Uses Zoom's granular scope names (meeting:write:meeting, meeting:read:meeting, user:read:user) since umbrella scopes were deprecated mid-2024.
  • Zoom token store (apps/web/src/server/zoom/token-store.ts). Mirrors the Google one: encrypts refresh tokens at rest with the same AES-256-GCM key (GOOGLE_TOKEN_ENCRYPTION_KEY reused — same trust boundary), refreshes access tokens lazily, marks integrations revoked on invalid_grant. Shares the existing per-tenant circuit breaker — keyed ${tenantId}:zoom to keep Zoom outages from tripping Google's circuit and vice versa.
  • `/api/v1/integrations/zoom/connect` (OWNER-only) — kicks off OAuth with a signed state token, mirroring the Google connect route.
  • `/api/v1/integrations/zoom/callback` — public route, verifies the signed state, exchanges code for tokens, fetches the connected user's profile, and upserts a ZoomIntegration row. Returns the same meta-refresh+JS HTML page Google uses to dodge the Safari ITP cookie-on-redirect issue.
  • `GET /api/v1/integrations/zoom` + `DELETE /api/v1/integrations/zoom` — read connection state for the settings card; disconnect (best-effort revoke at Zoom + mark revokedAt locally).
  • `ZoomIntegrationCard` in Settings → Integrations, mounted next to the existing Google Meet card. Identical Connect / Disconnect UX. Renders nothing when the deployment hasn't set ZOOM_ENABLED=true, same pattern as Google.
  • `deriveZoomTier` helper that maps Zoom's user.type (1=Basic, 2=Licensed, 3=On-Prem) into a ZoomTier enum. Surfaced as a regression test so we notice if Zoom ever changes the numeric mapping. Not yet rendered in the UI — that's Commit 2.

noteSchema

  • 20260503110000_zoom_integration — adds the ZoomIntegration table. One row per tenant (tenantId UNIQUE), nullable revokedAt, encrypted refresh + access tokens, scopes array, optional zoomUserId + zoomAccountId for tier checks. FKs cascade from Tenant; restrict from User. Purely additive.

noteNotes

  • 226 web tests pass (was 221 in v4.9.11 → +5: deriveZoomTier).
  • Migration applied to prod RDS via ECS one-shot before this image rolls.
  • ECS web task def rev 26 registered with ZOOM_CLIENT_ID / ZOOM_CLIENT_SECRET from AWS Secrets Manager and ZOOM_ENABLED=true.
  • Connect button on prod sends users through the Zoom consent screen against the live Marketplace app HelpMesh Meetings (Client ID 1Mje8gZdRuG4Elrob10f3g). The OAuth callback URL on Zoom's side is https://helpmesh.io/api/v1/integrations/zoom/callback, which only resolves once this image is rolled.
  • No tenant-visible behavior change for tenants who have not connected Zoom — the card stays in the "Available" state until they click Connect.

noteWhy now

Workspace tier detection in v4.9.11 surfaces a yellow warning for free Gmail accounts: "free accounts may hit Workspace policy limits." Without Zoom, that warning is "sorry, you're stuck." Zoom is the answer — and PropVista already has Zoom from their real-estate stack, so they're the canary tenant once Connect ships.

v4.9.11

PR-B: Meet operational quality. Three sub-tasks bundled — token refresh circuit breaker, Workspace tier detection, and a Reports → Meetings tab — so the deploy churn is one image, one rollout, one verification cycle. None of the three is load-bearing on its own; together they harden the Meet integration from "works when Google works" to "works gracefully even when Google doesn't, and surfaces the right warning before the agent hits a Google policy wall."

featureAdded

  • Per-tenant token-refresh circuit breaker (apps/web/src/server/google-meet/refresh-circuit.ts). After 3 consecutive non-invalid_grant refresh failures, the circuit opens for 30 seconds and fast-fails with 503 HM-GOOGLE-TEMPORARILY_UNAVAILABLE + Retry-After. HALF_OPEN probe success closes the circuit; probe failure re-opens for another full window. invalid_grant resets the circuit (no point backing off after revoke). Wired into /threads/:id/schedule-meeting, PATCH /scheduled-meetings/:mid, and DELETE /scheduled-meetings/:mid. Process-local in-memory store; per-instance is enough to stop a single web pod from hammering Google during an outage. 12 tests with deterministic injectable clock — no fake timers.
  • Google Workspace tier detection on the OAuth callback. Reads the OpenID hd (hosted domain) claim from /v1/userinfo: present → WORKSPACE (paid Google Workspace org), absent → CONSUMER (free @gmail). New GoogleIntegration.workspaceTier (enum) + workspaceDomain columns. The settings card shows a green "Workspace · {domain}" badge for paid accounts and a yellow "Free Gmail account connected — may hit policy limits" warning for consumer ones. When a CONSUMER-tier account hits the Workspace policy 403 mid-create, the route returns the new 409 HM-GOOGLE-WORKSPACE_REQUIRED with copy that points the user at the reconnect button — instead of the previous generic 502 HM-GOOGLE-CALENDAR_FAILED that left the agent guessing what to do. 7 tests for the deriveWorkspaceTier helper.
  • Reports → Meetings tab. New /api/v1/reports/meetings endpoint that aggregates ScheduledMeeting data over the same date-range params as /reports/volume (rolling ?days=N or custom ?from=&to=). Returns totals, status breakdown, daily volume, top organizers, and average/median duration in one round-trip. The tab itself slots into the existing Reports page (Overview / Agents / SLA / CSAT / Meetings) with a stat-card row, a daily volume area chart, a status pie, and a top-organizers table. Empty state when the tenant hasn't scheduled anything yet. 13 tests for computeMeetingTotals + computeDurationStats.

featureChanged

  • getValidAccessToken now wraps Google's token endpoint in the circuit breaker. The transient/INVALID_GRANT split is preserved — invalid_grant still marks the integration revoked and surfaces GoogleMeetNotConnectedError, unchanged from v4.9.x behavior.
  • GET /api/v1/integrations/google includes workspaceTier and workspaceDomain so the settings card can render the badge/warning without a second round-trip.
  • UserInfo in @helpmesh/channels adds an optional hd field. Backward-compatible — existing callers ignore it.

noteSchema

  • 20260503100000_google_workspace_tier — adds the GoogleWorkspaceTier enum (UNKNOWN | CONSUMER | WORKSPACE) and two columns on GoogleIntegration: workspaceTier (NOT NULL, defaults UNKNOWN) and workspaceDomain (nullable). Pre-tier-detection rows backfill to UNKNOWN; the next reconnect overwrites. Purely additive — old code paths keep working at upgrade time.

noteNotes

  • 221 web tests pass (was 189 before PR-B = +32: 12 circuit, 7 tier, 13 stats). Typecheck + lint + build clean across all 15 packages.
  • Migration applied to prod RDS via ECS one-shot mid-deploy, before the new web service rolls — per the standard runbook in docs/DEPLOYMENT.md.
  • No behavior change for tenants who have never connected Google Meet — the integration card stays hidden until enabled.

v4.9.10

DX review C2-C5 fixes plus two regressions surfaced in the v4.9.9 retest. Public DX score moves from 3.3/10 toward the 7+ target by giving the API its own /docs index, a webhooks reference, the industry-shape thread payload, and distinct auth error codes. The retest itself caught a broken logo in the reply email (s3:// URI Gmail can't fetch) and a missing entry in the settings sidebar for the new Profile page — both bundled here so we don't ship two releases for one surface.

fixFixed

  • Reply / meeting email logo broken in Gmail. branding.logoUrl is stored as s3://bucket/key (canonical form set by the upload flow). The reply and meeting-invite templates inlined that value as <img src="...">, which Gmail / Outlook / Apple Mail can't load — they only fetch http(s). New resolveLogoUrlForEmail helper in @helpmesh/channels rewrites s3:// to either ${LOGO_PUBLIC_BASE_URL}/{key} (when a CDN base is configured) or a 7-day SigV4 presigned GET URL (default). Wired into the reply route, schedule-meeting route, and the PATCH/DELETE meeting routes. Pure http(s) URLs and null pass through untouched. Surfaced from PropVista's v4.9.9 retest screenshot — accent + signature + footer rendered correctly, only the logo was a broken-image icon.
  • AWS SDK `x-amz-checksum-mode=ENABLED` baked into presigned URLs. @aws-sdk/client-s3@3.729+ adds default integrity protections that emit x-amz-checksum-mode=ENABLED and x-amz-sdk-checksum-algorithm=CRC32 as unsigned query params on GetObjectCommand presigns. S3 then 403s the GET because the URL carries unsigned params the signature didn't cover. Configured S3Client with requestChecksumCalculation: 'WHEN_REQUIRED' + responseChecksumValidation: 'WHEN_REQUIRED' so presigned download URLs work in <img> tags and /api/v1/assets/resolve returns URLs the browser can actually load. Same root cause was also breaking the in-app branding settings logo preview.
  • `/settings/profile` missing from settings sidebar nav. The page shipped in v4.9.9 was only reachable via direct URL. Added a new "My Account" section at the top of apps/web/src/app/(agent)/settings/layout.tsx with a "My Profile" entry (minRole: 'VIEWER') so every role — Viewer, Agent, Admin, Owner — can find and edit their own profile + signature.

featureAdded

  • Distinct API authentication error codes (DX C2). apiHandler now distinguishes HM-AUTH-MISSING (401, no auth header), HM-AUTH-INVALID_KEY (401, malformed or non-existent key), and HM-TENANT-NOT_FOUND (404, key valid but tenant disabled or deleted). Previously every auth failure collapsed into a generic 404, leaving integrators with no way to tell whether their key was wrong or the endpoint didn't exist. 5 new tests in api-handler-auth-codes.test.ts lock the contract.
  • `/docs` marketing index page (DX C3). Public landing page that links the three real reference surfaces — REST API (/docs/api), Webhooks (/docs/webhooks), and Changelog (/changelog) — instead of 404-ing as it did in v4.9.9. Indexable for AI crawlers per the marketing carve-out.
  • `/docs/webhooks` reference (DX C4). Documents the eleven webhook event types HelpMesh emits, the HMAC-SHA256 signature header format, the retry policy (10 failures = auto-disable + email), and a copy-paste signature verification snippet in Node.js + Python. Pairs with the existing per-tenant webhook UI under /settings/webhooks.
  • Industry-shape thread payload in `/docs/api` (DX C4). The auto-generated OpenAPI page now ships a hand-written example of the relaxed thread shape (customer.email, customer.name, subject, message) accepted since v4.8.1, alongside the strict customer_id form. Both shapes return 201 with the same body — integrators can pick whichever fits their existing data model.
  • **JSON 404 fallback for unknown /api/v1/* routes.** New catch-all at apps/web/src/app/api/v1/[...notfound]/route.ts returns the standard { error: { code: 'HM-NOT_FOUND', message: '...' } } envelope instead of Next.js's HTML 404 page. Resolves the v4.9.9 case where mistyped paths returned an HTML page that integrators had to inspect to debug.

noteWhy now

v4.9.9 shipped LIVE on prod and verified 5/5 of its automated checks, but the user retest in Gmail caught two real-world bugs: the logo URI broke for Gmail, and the new profile page was unreachable except by direct URL. Bundling the C2-C5 DX fixes (already staged on disk, gates passing) with these two regression fixes means one Docker build, one ECS deploy, one CHANGELOG entry — instead of the v4.9.7/4.9.8 churn pattern.

v4.9.9

Reply email gets a major polish — logo header, accent border, tenant signature (with per-agent override), website + support email footer. Surfaced from PropVista's first formatted reply on prod: the email arrived but looked too sparse ("HelpMesh Admin / PropVista" with no logo, no website, no signature). v4.9.9 lets every tenant make outbound replies look like a real piece of brand communication.

featureAdded

  • Tenant default reply signature (#69). New Tenant.replySignature column (sanitized HTML). Edited via Settings → Workspace → Branding → Reply Email card with a compact Tiptap editor (Bold / Italic / Underline / Bullet list / Link). Used on every agent reply unless that agent has set their own override.
  • Per-agent signature override (#69). New User.replySignature column. Edited at Settings → My Profile (new page) by any agent. When set, replaces the tenant default for replies sent by that agent. Resolution: agent override > tenant default > fallback to bare {agentName}\n{tenantName}.
  • Tenant website URL (#69). New Tenant.websiteUrl column. Rendered in the reply email footer ("propvista.io · support@propvista.io"). Strips the https:// prefix in the display for a cleaner look.
  • Reply email logo toggle (#69). New Tenant.replyShowLogo column (defaults true). Lets a tenant suppress the logo header in replies without removing it from their tenant record (useful when the logo doesn't sit well at small heights).
  • `/api/v1/users/me` GET + PATCH route. Self-only — agent reads/writes their own profile editable fields (today: replySignature; expandable as the profile surface grows). Never accepts tenantId or role; those go through /users/:id under ADMIN gating.
  • Reusable `SignatureEditor` component (apps/web/src/components/settings/SignatureEditor.tsx). Compact Tiptap setup tuned for signatures (no headings, no blockquotes, no code blocks). Shared by the tenant default editor and the agent profile override.

featureChanged

  • `renderReplyEmail` template polish — accent-color top border (3px solid branding.primaryColor), bigger logo (40px high vs 32px), tighter padding (28px vs 24px), 15px body type at 1.65 line-height (was 14px @ 1.6). Footer carries the website link + support email separated by a thin middot, with the website link colored by the tenant's primary brand color. Existing v4.9.6 <p><div> body wrap retained (the WYSIWYG nested-block fix). When signatureHtml is provided, replaces the bare name block with a styled signature; when null, falls back to the pre-v4.9.9 minimal {agentName}\n{tenantName}.
  • /api/v1/tenant GET + PATCH now reads + writes websiteUrl, replySignature, replyShowLogo. Empty strings are normalized to null (UI clear → server clears).
  • /api/v1/threads/:id/reply route resolves the signature via agentUser.replySignature ?? thread.tenant.replySignature ?? null and passes it into renderReplyEmail along with branding.websiteUrl and branding.showLogo.

noteSchema

  • 20260502120000_reply_signatures_and_branding — adds Tenant.websiteUrl TEXT, Tenant.replySignature TEXT, Tenant.replyShowLogo BOOLEAN NOT NULL DEFAULT true, User.replySignature TEXT. All four are nullable / safely defaulted — no behavior change at upgrade time. Applied to prod RDS via ECS one-shot before this image rolled.

noteNotes

  • 184 web tests + 11 channels tests pass (16 v4.9.9-relevant: 6 new email-template assertions for accent border / logo toggle / website link / support email / signature presence; 5 pre-existing kept). Typecheck clean across all 15 packages.
  • No behavior change for tenants who haven't configured the new fields — the template gracefully falls back to the v4.9.8 layout when website / signature / logo-toggle are unset.

noteOrigin

PropVista's first end-to-end reply screenshot in Gmail showed the customer received "te" rendered in a small bare card with just "HelpMesh Admin / PropVista" underneath — functional but visually sparse. User asked for: logo + website + tenant-customizable signature. v4.9.9 ships all three plus the agent override.

v4.9.8

v4.9.7's Docker build failed because .dockerignore excluded ALL markdown files via *.md (with only !README.md as an exception). Both v4.9.6's outputFileTracingIncludes config AND v4.9.7's explicit COPY --from=builder /app/CHANGELOG.md failed for the SAME upstream reason — the file was filtered out of the Docker build context before either approach could see it. v4.9.7's empty-bubble Pusher routing fix never reached prod either because the build itself failed.

fixFixed

  • `.dockerignore` excludes CHANGELOG.md from the Docker build context (#68). Added !CHANGELOG.md to the exception list. Now both the trace-bundling (v4.9.6) and the explicit Dockerfile COPY (v4.9.7) work — belt-and-suspenders. The 3-letter file finally lands at /app/CHANGELOG.md inside the standalone container, the marketing /changelog page can read it at runtime, and v4.9.7's empty-bubble fix ships alongside.

noteNotes

  • Pure config change — 1 line in .dockerignore. No code, no schema, no migration.
  • Re-ships everything queued in v4.9.7 since that build never produced an image: empty-bubble Pusher routing fix + Dockerfile COPY for CHANGELOG.md.
  • Rebuilds the changelog page so the v4.9.6 + v4.9.7 + v4.9.8 entries finally render publicly.

noteOrigin

Discovered via the v4.9.7 deploy verification: gh run view --log-failed showed ERROR: "/app/CHANGELOG.md": not found on the COPY line. Tracing back: the builder stage's COPY . . was honoring .dockerignore's *.md exclusion, so CHANGELOG.md was never in the builder filesystem, so --from=builder /app/CHANGELOG.md couldn't find it. One-line root cause fix.

v4.9.7

Two hotfixes for issues that surfaced after v4.9.6 hit prod: (1) the "Invalid Date / System" empty-bubble ghost render in the inbox after an agent reply, (2) the marketing changelog page STILL serving an error fallback because outputFileTracingIncludes silently dropped the file in CI Docker builds (it worked in local builds — classic).

fixFixed

  • Inbox empty-bubble ghost render (#67). useRealtime was binding both the tenant-wide private-tenant-{id} channel thread:message event AND the thread-level private-thread-{id} channel message:new event into the SAME onNewMessage callback. The tenant-wide event carries a stub { threadId, messageId, preview } (no full message body), and when its threadId matched the open thread, the stub got appended directly into the thread timeline — rendering as "System / Invalid Date" with no body because the renderer expected id / body / createdAt fields the stub doesn't have. Split into a new onCrossThreadActivity callback that handles the stub correctly: refreshes the list, shows a toast for messages in OTHER threads, never appends to the open thread. Plus a defensive guard on onNewMessage itself that ignores any payload missing id or createdAt, so a future stub leak can't ghost-render again. 7 new unit tests in realtime-routing.test.ts lock the routing decision in.
  • Marketing changelog page (#67, take 2). v4.9.6's outputFileTracingIncludes: { '/changelog': ['../../CHANGELOG.md'] } works in local next build (verified .next/standalone/CHANGELOG.md was present after a local build) but the file does NOT appear in the standalone output during CI Docker builds. Could not pin down whether it's a pnpm-virtual-store layout interaction or a tracer bug, but the symptom was deterministic: prod logs show CHANGELOG.md not found in any of: /app/CHANGELOG.md, ... after every v4.9.6 boot. Replaced the trace-bundle approach with an explicit COPY --from=builder /app/CHANGELOG.md /app/CHANGELOG.md in the Dockerfile. Tracker-independent, works regardless of next.js internals, file definitely lands at /app/CHANGELOG.md.

noteNotes

  • 184 tests pass total (was 177 in v4.9.6). Typecheck + lint + build clean.
  • No schema, no migration, no new dependencies.
  • The v4.9.6 outputFileTracingIncludes config stays in next.config.mjs as belt-and-suspenders — if the trace ever starts working the file lands twice harmlessly.

noteOrigin

PropVista's first formatted reply on prod after v4.9.5/v4.9.6 surfaced the empty-bubble bug visually (DB inspect on PRO-6-000016 showed only 2 messages, but the UI showed 3). Pusher payload-shape investigation traced the ghost back to the tenant-wide stub event being miswired into the message-append handler. Found alongside the still-broken changelog page during the v4.9.6 verification pass.

v4.9.6

WYSIWYG composer expansion + inbox default routing + Meet edit UI + a hotfix for the v4.9.5 changelog page that couldn't find CHANGELOG.md inside the standalone Docker image. Five user-facing improvements bundled because they all surfaced from the PropVista integration handoff.

fixFixed

  • Marketing changelog page (#66). v4.9.5 shipped a markdown-driven page but the standalone Next.js build doesn't copy CHANGELOG.md from the monorepo root into the container, so prod served an error fallback. Added outputFileTracingIncludes: { '/changelog': ['../../CHANGELOG.md'] } to next.config.mjs plus extra path candidates in findChangelogPath() (handles the standalone path layout /app/CHANGELOG.md).

featureAdded

  • Composer rich-text toolbar (#66). Expanded from 4 buttons to 13 — Bold / Italic / Underline / Strikethrough / Heading 1 / Heading 2 / Bullet list / Numbered list / Blockquote / Link / Code block / Horizontal rule, organised into 4 logical groups separated by visual dividers. New deps: @tiptap/extension-link and @tiptap/extension-underline (both ~5KB, official Tiptap v2.10.0). Link button uses window.prompt for now — a richer floating bubble can land later. Each button has title + aria-label for keyboard + screen-reader access.
  • Inbox default routing flag (#66). New Inbox.isDefault Boolean @default(false) column with partial unique index WHERE isDefault = true so at most one default per tenant. POST /api/v1/threads now resolves the auto-routed inbox via: explicit inboxId > tenant's marked-default > oldest active EMAIL/API inbox. Admins can flip the default via the new "Set as default" link on Settings → Inboxes (or the API: PATCH /api/v1/inboxes/:id { "isDefault": true }). When 2+ eligible inboxes exist with no default marked, a warning banner explains the ambiguity. Migration 20260502100000_inbox_is_default is additive + idempotent — the backfill marks each tenant's oldest active EMAIL/API inbox as default to preserve pre-flag behavior, so no existing tenant experiences a routing change at upgrade time.
  • Meeting edit UI (#66). New Edit button between Join and Cancel on MeetingMessageBubble for upcoming meetings. Opens an inline modal with title / when / duration / description fields, calls the existing PATCH /api/v1/threads/:id/scheduled-meetings/:mid endpoint (live since v4.9.0), only POSTs the changed fields, surfaces HM-GOOGLE-MISSING_SCOPE cleanly. Closes the C4 deferred item from PR-A.

featureChanged

  • Reply email body wrapper (#66). renderReplyEmail previously wrapped the agent's HTML body in <p>...</p>, which produced invalid nested-block markup (<p><h1>...</h1></p>, <p><ul>...</ul></p>) when the agent used the WYSIWYG toolbar — Gmail and Outlook collapsed the result into a single inline line. The wrapper is now a <div> carrying the same typography styles, scoped via that one element so child block elements render correctly. 5 new regression tests in packages/channels/src/email/render-reply-email.test.ts cover multi-paragraph, headings, lists, blockquotes, and the explicit "no nested <p>" guard.

noteSchema

  • 20260502100000_inbox_is_default — adds Inbox.isDefault BOOLEAN NOT NULL DEFAULT false, backfills oldest active EMAIL/API inbox per tenant, creates partial unique index Inbox_tenantId_isDefault_unique WHERE isDefault = true. Idempotent (uses IF NOT EXISTS). Migration was applied to prod RDS via ECS one-shot before this image rolled — 3 tenants backfilled.

noteDeploy notes

  • Migration must apply BEFORE the v4.9.6 image rolls — done manually before the tag push. deploy.yml continues to ship code only.
  • Two new dependencies: @tiptap/extension-link@^2.10.0 and @tiptap/extension-underline@^2.10.0. Both are official first-party Tiptap packages, MIT, sub-10KB minified each.
  • 177 tests pass total (was 130 at session start). Typecheck + lint + build all clean.

noteOrigin

PropVista's prod integration handoff this morning surfaced four issues: (1) the v4.9.5 changelog page rendered as an error fallback, (2) ticket replies on API-channel threads weren't reaching the customer's email (already fixed in v4.9.5), (3) replies that DID reach the email were rendered as a single line because the <p> wrap broke multi-paragraph WYSIWYG, (4) auto-routing of new tickets was non-deterministic across multiple inboxes. Bundle ships all four fixes plus the C4 Meet edit UI that was already-deferred from v4.9.0.

v4.9.5

P0 hotfix bundle — agent replies were silently dropping email delivery for any thread created via the API (PropVista et al), the changelog page was 4 versions stale, and app.helpmesh.io / api.helpmesh.io were aliasing the marketing site. Three independent issues, fixed together because they were all blocking integration handoff today.

fixFixed

  • Agent replies on API-channel threads now actually email the customer (P0, prod). The /api/v1/threads/[id]/reply route gated outbound Postmark sends on thread.channel === 'EMAIL'. Threads created via POST /api/v1/threads (PropVista's integration path) get channel = 'API', so every agent reply since v4.8.1 silently saved to DB without ever being emailed. The customer's email address is the actual signal for delivery — channel just records origin. Decision moved to a pure helper shouldSendReplyEmail(thread) and locked behind a regression test that explicitly asserts API-channel threads with customer email DO send.
  • Agent reply messages now persist `authorId` (P0, prod). The reply route was creating Message rows with authorType: 'AGENT' but authorId: null, so the inbox UI couldn't resolve the user and rendered every agent reply as "System". Same pattern as ScheduleMeeting — read session.user.id and pass it through.

featureAdded

  • Markdown-driven changelog page (#65). apps/web/src/app/(marketing)/changelog/page.tsx now parses CHANGELOG.md at build time and renders all 40 historical releases automatically. Replaces the stale hand-maintained RELEASES array that capped at v4.7.6. New apps/web/src/lib/changelog-parser.ts (pure function, 17 unit tests covering format edge cases) plus inline markdown rendering for **bold** / ` code / links` so release notes render with proper typography. Section types (Added / Changed / Fixed / Security / Notes / Schema / etc.) get color-coded badges.
  • Meet defensive scope guard (#65). /api/v1/threads/[id]/schedule-meeting and the edit/cancel routes now verify the stored OAuth scopes include https://www.googleapis.com/auth/calendar.events BEFORE calling Google. If missing, returns HM-GOOGLE-MISSING_SCOPE with an actionable message ("Disconnect and reconnect, then grant Calendar access on the Google consent screen") instead of bubbling Google's generic 403 Insufficient Permission as a 502. Surfaced today after a Google Cloud Console misconfiguration silently shipped a Meet integration without calendar.events in the OAuth client's allowed scope list. New hasCalendarScope() helper + 7 unit tests.
  • Host-aware routing for `app.helpmesh.io` and `api.helpmesh.io` (#65). Until now both subdomains aliased the marketing site at helpmesh.io. Middleware now redirects app.helpmesh.io//inbox (which then bounces to /login if no session) and api.helpmesh.io/{anything-not-/api/}/api/v1/openapi. 11 new unit tests cover the routing decision including case-insensitive host match, boundary paths (/api), and unknown subdomain fall-through.

featureChanged

  • Reply route now reads email threading headers regardless of channel. Previously gated on channel === 'EMAIL'. Now an API-channel thread where the customer later replied via email still gets proper In-Reply-To/References stitching on subsequent agent replies, so the conversation threads in their mail client.
  • `ScheduleMeetingModal` surfaces `HM-GOOGLE-MISSING_SCOPE` with the precise reconnect instruction instead of "Could not schedule the meeting."

noteNotes

  • 170 tests pass (was 130 before the session); typecheck clean across all 15 packages.
  • No schema, no migration. Pure code change.
  • The Meet OAuth scope misconfiguration was tracked down via prod log + DB inspection: [google-meet] event create failed: 403 "Request had insufficient authentication scopes" plus GoogleIntegration.scopes missing calendar.events. Fixed in Google Cloud Console (added the scope to the OAuth client's consent screen + enabled Calendar API), then tenant reconnect picked up the new scope. The defensive guard in this release prevents the same silent-failure shape if a future tenant lands in the same state.
  • Manual Meet QA passed end-to-end: schedule → branded email arrives in Gmail with Yes/No/Maybe inline (RFC 5545 .ics METHOD:REQUEST rendering correctly) → calendar event created on organizer's Google Calendar with Meet link auto-attached.

noteOrigin

PropVista's first end-to-end thread reply test surfaced all three issues within an hour: (1) reply UI showed "System / Invalid Date" + customer never received email, (2) tried scheduling a Meet to confirm Meet QA #1 → 502 from missing OAuth scope, (3) noticed app.helpmesh.io and api.helpmesh.io both serving the marketing page. Bundled because all three blocked PropVista integration handoff.

v4.9.4

Refactor — centralized canonical URLs in @helpmesh/config. Future host migration (e.g. api.helpmesh.io subdomain per the Stripe-style separation flagged in the 2026-05-02 DX review) becomes a one-line ECS env var change instead of a 30-file audit. Plus a fix that makes next build more robust.

featureAdded

  • Canonical URL helpers in `@helpmesh/config` (#64). Three pure helpers (apiBaseUrl, marketingUrl, appUrl) plus apiV1Url convenience. Resolution chain: specific env var → NEXT_PUBLIC_APP_URL → hardcoded production default https://helpmesh.io. 13 unit tests cover the chain, normalization, precedence.
  • Lazy env validation (#64). @helpmesh/config's env is now a Proxy that runs validateEnv() on first property access instead of at module load. Build-time imports work without runtime env vars; runtime code that reads env.X still fails loudly on misconfiguration. The previous module-load validation was vestigial — no caller actually imported env.

featureChanged

  • 14 files (route handlers, sitemap, robots, layout meta, OpenAPI spec generators, SDK doc strings, 4 worker email-link processors, marketing-seo helper, docs/api page, /developers page) now read URLs from the canonical helpers instead of hardcoded https://helpmesh.io. End-customer behavior unchanged — same URL today, just templatable.
  • apps/web/src/app/developers/page.tsx curl examples updated alongside: payload shape from old {subject, customer:{...}} to current v4.8.1 industry shape {title, body, requester:{...}}, key prefix from sk_ to pk_ matching what the system actually generates.

noteNotes

  • 10 hardcoded https://helpmesh.io references intentionally kept (Stripe appInfo.url metadata, SDK shipped to customer envs, in-template logo URLs, embeddable widget "Powered by", legal/KB body text, OAuth provider callback URLs that must match what's registered, test fixtures). Documented per file in the PR.
  • 432 tests pass total (173 core + 130 web + 116 channels + 13 new config); typecheck clean across all 15 packages. No schema, no migration, no new dependencies.

noteOrigin

Live /devex-review audit on 2026-05-02 scored public DX 3.3/10 and flagged C1: OpenAPI spec, /docs/api page, and SDK DEFAULT_BASE_URL all hardcoded https://helpmesh.io. Audit revealed 52 occurrences across 30 files. The 3-constant fix listed in the review wouldn't have solved the underlying problem; this refactor does, while keeping PropVista's working integration intact.

v4.9.3

Two small DX wins for integrators (PropVista et al), bundled because they touch overlapping concerns (debuggability + response payload shape).

featureAdded

  • Structured response logs on every 4xx/5xx (#63). apps/web/src/lib/api-handler.ts now emits a single trailing log line on every response with status >= 400: [api] response {requestId, tenantId, path, method, status, durationMs}. Severity scales (5xx → console.error, 4xx → console.warn). 2xx/3xx skipped to keep CloudWatch quiet. Wrapped 9 return paths through a single logAndReturn(response) helper so it's structurally impossible for a new error path to bypass the log. Closes the gap that made PropVista's debug session require three round-trips earlier today — every requestId in a response body is now greppable in CloudWatch.
  • Server-derived `ticketNumber` field on `/threads` responses (#63). The agent inbox UI has formatted "PRO-6-000014" client-side since v3.x. The helper has been extracted to @helpmesh/core's new formatTicketId(tenantName, publicNumber, now?) and the API now returns the formatted string in both POST /api/v1/threads and GET /api/v1/threads/:id responses. Integrators (PropVista et al) can read data.ticketNumber directly instead of re-deriving the format on their side. 7 unit tests cover the standard shape, padding boundaries, year-digit rollover, short tenant names, and the timezone-sensitive year gotcha.

noteNotes

  • No schema, no migration. Pure code change.
  • formatTicketId shape unchanged from the existing UI helper: {first 3 letters of tenant name uppercased}-{last digit of current calendar year}-{publicNumber zero-padded to 6 digits}. Example: PropVista + 14 + 2026 → "PRO-6-000014".
  • 419 tests pass total (173 core + 130 web + 116 channels); typecheck clean across all 15 packages.

v4.9.2

Polish — API key creation UX now matches the webhook UI's per-group "Subscribe to all" pattern, plus a top-level "Grant all scopes" toggle for full-access integrators.

featureAdded

  • Per-group "Subscribe to all" / "Clear all" link on each scope group header (Threads, Customers, Webhooks, etc.). Mirrors the equivalent button on the webhook event picker.
  • Top-level "Grant all scopes" / "Clear all" link in the Scopes section header. One click selects every scope in the catalog at once — useful for canary tenants or full-trust integrators that want a wide-scope key without ticking 20+ boxes.

Selected-state computation is independent: a partial selection in one group shows "Subscribe to all" on the group header but "Grant all scopes" on the section header — same UX shape as the webhook page.

noteNotes

  • Pure UI change. POST /api/v1/api-keys already accepted any subset of scopes; toggling them in batch is purely React state. No new endpoints, no schema migration.
  • Origin: requested 2026-04-30 by the user after creating PropVista's first API key. Saved as a memory TODO; pulled into this slot while RDS Phase 2 was paused waiting on PropVista's deploy.

v4.9.1

Hotfix — agent-side timeline rendered every agent reply twice. The duplicate appeared on the wrong side of the conversation (CUSTOMER lane) with Invalid Date because the Pusher broadcast payload doesn't carry the same field shape as the REST GET response.

fixFixed

  • Realtime message dedup (#61). useRealtime's onNewMessage handler now skips appending a message when one with the same id already exists in prev.messages. Without this, both the post-send fetchThreadDetail refetch and the Pusher message.created broadcast added the same row to local state, so every agent reply rendered twice.

noteReproduction

PropVista's first prod thread cmony8ssb00092sw9cgjcvaek (PRO-6-000014). DB had exactly 3 messages; UI showed 5 bubbles after both agent replies. Confirmed via direct DB inspection (ECS one-shot query).

noteWhy hotfix

Affects every agent reply on every thread. Visible to PropVista in the first ticket they created today (06:17 UTC). 5-line frontend change, zero schema impact, no migration.

v4.9.0

Google Meet "feature-complete" — moves the integration from "OAuth works + minimal scheduling" to a complete agent-grade workflow that ships every piece of the meeting lifecycle with proper forensic logging and customer-facing email quality.

featureAdded

  • Branded meeting invite email + RFC 5545 .ics attachment (PR #60). Replaces the bare paragraph reply with a structured invite card (logo, time/duration card, Join button, optional agenda) and attaches a real iCalendar invite with METHOD:REQUEST so Gmail / Outlook / Apple Mail render Accept/Decline + one-click "add to calendar" inline. New packages/channels/src/email/ics-builder.ts (UTF-8 line folding, TZID-aware times, escape rules per spec) and meeting-invite-template.ts (XSS-safe, allowlists logo URL + brand color).
  • Edit + cancel endpoints (#60). PATCH /api/v1/threads/[id]/scheduled-meetings/[mid] patches the Calendar event with sendUpdates=all, bumps ScheduledMeeting.icsSequence, sends a fresh invite email so mail clients update the existing event instead of creating a duplicate. DELETE on the same path deletes the Calendar event, sends a branded cancellation email with METHOD:CANCEL .ics (so the customer's calendar withdraws automatically), and captures an optional reason that's surfaced in both the email and the AuditLog row.
  • Attendance tracking (#60). New MeetingStatus.ATTENDED enum value plus PATCH /api/v1/threads/[id]/scheduled-meetings/[mid]/attendance accepting outcome: "ATTENDED" | "NO_SHOW". Constraints: meeting must be past + not cancelled; idempotent on re-mark; powers Reports → Meetings tab landing in PR-B.
  • Inline timeline cards (#60). When the agent opens a thread that has scheduled meetings, those meeting messages now render as a state-aware MeetingMessageBubble with action buttons appropriate to the current state — upcoming gets Join + Cancel; past + unmarked gets Mark attended / No-show; cancelled gets strikethrough title + reason; resolved states get a coloured badge. Replaces the raw branded HTML body that the customer received.
  • "Insert Meet link" Composer button (#60). Video icon in the bottom action bar (TEXT mode only). Clicking it opens the existing schedule modal; on success drops a hyperlinked join-link snippet into the agent's draft. Customer gets the structured invite (with .ics) plus the agent's personal note around the link.
  • Recording paste + send (#60). PATCH /api/v1/threads/[id]/scheduled-meetings/[mid]/recording accepts a recording URL + optional note, stamps ScheduledMeeting.recordingUrl, ships a branded reply via Postmark threaded off the original invite, fires meeting.recording_shared events. Drive auto-grab deferred — agent pastes the link from Drive's notification email. UI button surfaces on past meeting bubbles.
  • AuditLog on every meeting state change (#60). meeting.scheduled / meeting.updated / meeting.cancelled / meeting.attendance_marked / meeting.recording_shared rows capture actor, before/after, request IP + user agent. ISO 27001 alignment for the meeting workflow.
  • 5th starter KB article (#60). "Scheduling video calls with Google Meet" — covers Connect, Schedule, Edit/Cancel, Attendance, Recording, Composer integration, and Troubleshooting. Auto-published to new tenants via seedKbStarterArticles().

noteSchema

  • 20260502000000_meeting_sequence_invite_msgid_cancel_reason — adds ScheduledMeeting.icsSequence, inviteMessageId, cancellationReason.
  • 20260502010000_meeting_status_attendedALTER TYPE MeetingStatus ADD VALUE 'ATTENDED' (with IF NOT EXISTS guard).
  • 20260502020000_message_scheduled_meeting_fk — adds Message.scheduledMeetingId typed nullable FK to ScheduledMeeting with ON DELETE SET NULL.

Three migrations, all additive (nullable / defaulted columns, no backfill required):

noteDeploy notes

  • MUST run `prisma migrate deploy` via ECS one-shot before web service rolls. Per project convention, deploy.yml ships code only.
  • No new env vars.
  • 28 net-new tests in @helpmesh/channels covering ics-builder, meeting-invite-template, sendMeetingInvite + sendMeetingCancel, patchMeetEvent + deleteMeetEvent. Total suite is 412 tests; typecheck clean across all 15 packages.

v4.8.1

Hotfix — industry-compatible payload shape on POST /api/v1/threads. Unblocks PropVista's API integration and lays the foundation for any future Zendesk/Freshdesk-style integrator to onboard with their natural payload.

featureAdded

  • Industry-compatible thread creation (#59). POST /api/v1/threads now accepts both the strict shape ({inboxId, customerId, subject, body, channel}) and the industry shape ({title, body, requester, external_reference_id, tags}) used by Zendesk, Freshdesk, Intercom, and most help-desk platforms. Canonical aliases win when both are supplied; mixed payloads work.
  • Idempotency on retry. New Thread.externalRef column stores caller-supplied identifiers (e.g. PropVista ticket id) and is unique per tenant. POSTing the same external_reference_id twice returns the existing thread instead of creating a duplicate — eliminates double-charge risk for integrators that retry on 5xx.
  • Auto-upsert customer + auto-default inbox. Industry-shape callers don't need to look up the inbox or pre-create the customer — the route handles both inline. Reduces a 3-call integration flow to a single POST.
  • Tag-to-label conversion. tags array becomes top-level Labels per tenant, attached via ThreadLabel. Reuses existing labels by name; doesn't duplicate.
  • 16 new unit tests in packages/core/src/thread/createThread.test.ts covering both shapes, alias precedence, all rejection paths, and unknown-field passthrough.

noteMigration

  • 20260430110000_thread_external_ref adds Thread.externalRef String? plus @@unique([tenantId, externalRef]) and @@index([externalRef]). Additive — nullable column, NULL doesn't conflict with the unique constraint, no backfill needed.
  • MUST run `prisma migrate deploy` via ECS one-shot task on prod RDS BEFORE web service rolls to v4.8.1. Per project convention, deploy.yml ships code only — never migrations.

noteWhy hotfix

PropVista's integration was blocked on payload-shape mismatch. The fix was already coded, tested (166 core + 130 web tests green, typecheck clean), and isolated. Decoupling it from PR-A (Meet feature-complete, ~22-26h work remaining) means PropVista unblocks today instead of next week, and any future integrator picks up the same compatibility benefit immediately.

v4.8.0

Four feature PRs ship together — security hardening, BYOD inbound symmetry, email setup self-service, and a public marketing site replacing the /login redirect at the root.

featureAdded

  • Rate limiter (#54). Wires the existing withRateLimit middleware into apiHandler so every v1 route gets per-IP (600/min) and per-tenant (3000/min) limits by default. Aggressive 5/min on auth/2FA/password-reset endpoints, 10/min on signup. NextAuth credentials POST is wrapped at the route layer so login attempts are rate-limited too. Behind RATE_LIMIT_ENFORCEMENT env flag (default false): observe-only mode logs every violation via console.warn so we can size limits before flipping enforcement on. 13 new unit tests.
  • BYOD Reply-To rewrite (#55). BYOD outbound now carries Reply-To: {slug}@inbound.helpmesh.io when the tenant has a verified custom domain, so customer replies route back to HelpMesh even when the customer-domain MX still points at Google Workspace or Outlook. Behind BYOD_REPLY_TO_REWRITE flag (default false). 6 new unit tests.
  • Marketing-only AEO carve-out (#55). Robots policy switched from "block all AI crawlers" to "allow on public marketing surfaces only." GPTBot, ClaudeBot, PerplexityBot, anthropic-ai, Google-Extended, etc. can index /, /pricing, /features, /blog, /compare/*, /docs, /changelog etc. Dashboards, API, customer data, admin tooling stay blocked. The X-Robots-Tag: noai, noimageai, noindex response header is now scoped to authenticated route prefixes instead of the global wildcard, so the carve-out actually takes effect.
  • Email-routing self-service (#56). Three additive surfaces explaining how email routes in BYOD vs trial workspaces. New EmailRoutingPanel on Settings → Inboxes shows three rows (outbound / inbound-on-reply / inbound-fresh) with copy buttons and BYOD-aware copy. New public /docs/email-setup page with optional ?slug= query param for per-tenant address templating, plus collapsible Workspace + Outlook forwarding walkthroughs and 5-question FAQ with FAQPage JSON-LD. New seedKbStarterArticles() auto-publishes 4 starter KB articles when a tenant is provisioned (self-serve and admin paths). Idempotent.
  • Public marketing site (#57). 16 pages under the (marketing) route group: Home, Pricing, Features, About, Contact, Careers, Security, FAQ, Blog (index + 2 posts), Compare (vs Zendesk / Intercom / Freshdesk), Integrations, Knowledge, API, Changelog. Every page indexable by Google + AI crawlers per the carve-out, full meta + OpenGraph + Twitter metadata, JSON-LD where appropriate (Organization, WebSite, SoftwareApplication, FAQPage, Article, BreadcrumbList). Sitemap auto-driven from a single MARKETING_PAGES registry.

featureChanged

  • Root `/` no longer redirects to `/login`. Instead, / serves the new HomePage. Logged-in users hitting / see the marketing home; they can navigate to /inbox from there or via direct URL.
  • `/pricing` migrated from app root into the (marketing) group so it picks up MarketingNav + MarketingFooter. Pricing FAQ + JSON-LD added.
  • MarketingNav + MarketingFooter updated with new sections — added "Compare" footer column, navigated /features instead of /product, etc.
  • Static `apps/web/public/robots.txt` deleted — was dead code (Next.js app/robots.ts wins) and conflicted with the new carve-out.

noteDeploy notes

  • Two new env vars on web ECS, both default to safe behavior:
  • No Prisma migrations required.
  • Scheduled remote agent (trig_01GXNH6S3d7qsZg3CXbW7wMw) will fire on 2026-05-14 to follow up on flag rollouts.

- RATE_LIMIT_ENFORCEMENT=false — observe-only. Flip to true after 24-48h soak with no false positives in [rate-limit] limit exceeded logs. - BYOD_REPLY_TO_REWRITE=false — disabled. Flip to true after smoke-testing on one BYOD tenant (suggest PropVista).

v4.7.6

Hotfix — MessageBubble crashed when rendering messages with attachments = null.

noteRoot cause

v4.7.5 fixed the agent reply path so /reply is now hit (which sends to Postmark — confirmed working: email.reply.sent event in prod logs with a real Postmark messageId). After the reply succeeded, fetchThreadDetail refetched the thread including legacy messages where Message.attachments was stored as null rather than []. The component declared attachments: Array<...> (non-nullable) and called attachments.length directly. Hit the null on the next render → TypeError: Cannot read properties of null (reading 'length') → Next.js error boundary → "500 / Something went wrong" page after every successful reply.

fixFixed

  • apps/web/src/components/agent/MessageBubble.tsx — interface now allows attachments: Array<...> | null, render code uses message.attachments ?? [] defensively. Comment explains the legacy-row context so future refactors don't undo it.

noteConfirmed working from this session's logs

The /reply route is succeeding end-to-end: `` [api] tenant resolved: ... /api/v1/threads/{id}/reply {"event":"email.reply.sent","threadId":"...","messageId":"f35dd412-..."} `` Postmark accepted the send, returned a real messageId, the email is on its way to the recipient.

v4.7.5

P0 hotfix — two production-critical bugs in the agent reply flow.

noteBug 1: Agent replies were never emailed (silent for the entire deployment)

The agent UI's "Send Reply" button hit POST /api/v1/threads/{id}/messages, which only persists the message to the DB. The route does not call Postmark. The actual email-sending path lives at POST /api/v1/threads/{id}/reply, which the UI never invoked. Result: every agent reply since the inbox shipped persisted in the DB, but no customer ever received the email. Confirmed by querying Postmark outbound history — zero agent-reply-tagged messages across 11 outbound emails (all weekly digests, agent invites, webhook alerts). The earlier "BYOD outbound verified end-to-end" test used a synthetic curl directly against /reply — the UI was never exercised.

noteBug 2: UI crashed with 500 page after sending

After the message persisted, handleSendMessage did an optimistic-append spreading the response into threadDetail.messages. The two endpoints (/messages vs /reply) returned slightly different message shapes; the renderer accessed properties on the appended row that the actual response didn't have, throwing in React → Next.js error boundary caught → "500 / Something went wrong" full-page error.

fixFixed

  • apps/web/src/app/(agent)/inbox/page.tsxhandleSendMessage now routes live agent replies (type: 'TEXT' with no scheduledAt) to /api/v1/threads/{id}/reply via the new pickSendMessageRoute helper. Notes (type: 'NOTE') and scheduled-sends still hit /messages (DB-only is correct for those).
  • Replaced the crashing optimistic-append with a thread refetch (fetchThreadDetail). ~50ms slower per send, doesn't crash.

featureAdded

  • apps/web/src/lib/send-message-routing.tspickSendMessageRoute() + buildSendMessageUrl() extracted to a pure, testable module so the routing decision can't silently regress.
  • apps/web/src/lib/__tests__/send-message-routing.test.ts — 9 unit tests including an explicit regression guard asserting type: 'TEXT' without scheduledAt must route to /reply, never /messages. CI fails if anyone refactors back into the broken behavior.

noteVerification once deployed

1. Open any thread from a real sender (e.g. jolly@ubrealty.com). 2. Type a reply, click Send Reply. 3. Expected: the message appears in the thread (no 500), and within ~10 seconds an email arrives at the sender's inbox from support@propvista.io (BYOD) with the agent reply body. 4. Check Postmark outbound activity — should see a new agent-reply tagged send.

v4.7.4

Inbox settings page now displays the most-branded support address available, mirroring the precedence in email.resolveOutboundFrom. Closes a white-label leak: the page hardcoded support@propvista.helpmesh.io literally in JSX (so any tenant loading the page saw PropVista's address regardless of their own slug), and the Inboxes list rendered the raw legacy propvista@inbound.helpmesh.io value even when a verified BYOD domain was active.

fixFixed

  • White-label tenant leak (cross-tenant cosmetic). Connected Channels → Email card and Email Configuration → Forwarding Address had support@propvista.helpmesh.io hardcoded as a literal string. Sendmesh / leasepulse / mortvista admins saw PropVista's address. Now derived from tenant.slug.
  • BYOD address not surfaced. Inboxes list showed inbox.emailAddress (the legacy Pattern A propvista@inbound.helpmesh.io) for the propvista tenant even after BYOD was verified on propvista.io. Now shows support@propvista.io for verified BYOD tenants.

featureAdded

  • apps/web/src/lib/display-address.tsresolveDisplayAddress helper. Priority: BYOD verified → slug subdomain → legacy inbox.emailAddress fallback. Configurable localPart for non-support inboxes (e.g. billing@).
  • apps/web/src/lib/__tests__/display-address.test.ts — 8 unit tests covering BYOD precedence, unverified-BYOD fallthrough, slug subdomain, legacy fallback, empty case, custom localPart, regression guard against inbound.helpmesh.io leak, BYOD-vs-legacy precedence.

featureChanged

  • apps/web/src/app/(agent)/settings/inboxes/page.tsx — three hardcoded address strings replaced with resolveDisplayAddress calls. Tenant fetch now also captures slug and emailDomainVerified.

v4.7.3

Hotfix — inbound to a verified BYOD domain was silently dropped. Discovered immediately after v4.7.2 verified the outbound path: customers replying to support@propvista.io would have their reply land at Postmark, get forwarded to our webhook, and the webhook would return 422 tenant_not_found. Their reply lost.

noteRoot cause

apps/web/src/app/api/webhooks/postmark/route.ts:68 looked up the tenant with: ``ts prisma.tenant.findFirst({ where: { customEmailDomain: primaryTo.email, emailDomainVerified: true }, }); ` But Tenant.customEmailDomain stores bare domains ("propvista.io"), and primaryTo.email is the full address ("support@propvista.io"`). They could never match. The custom-domain inbound branch had never been exercised end-to-end before today's verification — outbound shipped first, so the bug stayed dormant.

fixFixed

  • apps/web/src/app/api/webhooks/postmark/route.ts extracts the domain portion of the recipient (extractRecipientDomain) before looking up the tenant. Now-correct: customEmailDomain: "propvista.io" matches recipient "support@propvista.io".

featureAdded

  • apps/web/src/app/api/webhooks/postmark/route.test.ts — 7 unit tests on extractRecipientDomain: standard input, mixed case, subdomain, malformed, empty, plus-alias, and a regression guard asserting the result never contains @.

noteBehavior change

  • Replies from customers to BYOD-verified domains now land in the correct tenant inbox. Pre-existing *.helpmesh.io and inbound.helpmesh.io routing paths are unchanged.

v4.7.2

Hotfix — BYOD domain registration crashed on every fresh tenant. Caught in prod when admin clicked Continue on /settings/channels?tab=inboxes for propvista.io and got Internal Server Error.

noteRoot cause

Postmark's POST /domains endpoint splits DKIM fields into two pairs: DKIMHost/DKIMTextValue (the *active* key, populated only after first successful verifyDkim) and DKIMPendingHost/DKIMPendingTextValue (the key the tenant still needs to publish, populated immediately on create). For a freshly-registered domain — the only case registerBYODDomain ever encounters — the active fields are empty strings and the values live in the Pending fields. The wrapper checked only the active fields, threw on emptiness, and surfaced as a 500. Tests happened to set both pairs, so this never fired in CI.

fixFixed

  • packages/channels/src/email/byod-postmark.tsregisterBYODDomain now reads DKIMPendingHost || DKIMHost (and same for value), preferring Pending. Also handles the rare re-registration case where active fields might be set instead.
  • PostmarkDomainResponse interface adds DKIMPendingTextValue (was previously implicit / not declared).

featureAdded

  • packages/channels/src/email/byod-postmark.test.ts — two new tests: (1) freshly-registered domain with empty active + populated Pending fields (mirroring real Postmark response), (2) precedence test confirming Pending wins when both are set.

v4.7.1

BYOD part 4 — customer welcome email now uses the BYOD-aware From resolver as a fallback. Investigation during this PR also found that EmailSender.sendCsatSurvey and renderCsatEmail are dead code (defined, exported, never invoked); the categorical batch of "migrate everything outbound" from PR #46's notes was overscoped. Only paths that are actually customer-facing AND wired up should switch to the resolver — which today is just customer-welcome. The eight other paths called out in PR #46's "still pending" note (sendInviteEmail, sendPasswordReset, sendSignupVerification, sendTrialReminder, sendPaymentFailed/dunning, sendWebhookDisabled, sendWeeklyDigest, sendSlaBreachNotification) are HelpMesh-as-SaaS-vendor mail to a tenant admin. Switching them to the tenant's own domain would be wrong: an invoice "from" support@propvista.com for HelpMesh's subscription is confusing, breaks reply-handling, and triggers spam filters when the body says HelpMesh but the headers don't. Decision locked: those stay on noreply@helpmesh.io.

featureChanged

  • apps/worker/src/processors/customer-welcome.ts — extracted resolveWelcomeFromHeader (pure, testable). Manual override (cfg.fromEmail / settings.supportEmail) wins as before. When neither is set, falls back to email.resolveOutboundFrom instead of silently no-opping. Tenant query now also selects slug, customEmailDomain, emailDomainVerified.

noteBehavior change

  • Welcome email now actually sends for tenants that haven't manually configured a sender. Previously: silent skip + warn log. Now: queued from support@{customEmailDomain} (if BYOD verified) or support@{slug}.helpmesh.io.

featureAdded

  • apps/worker/src/processors/customer-welcome.test.ts — 8 unit tests for resolveWelcomeFromHeader covering manual override (with and without fromName), cfg.fromEmail precedence over settings.supportEmail, BYOD-verified fallback, subdomain fallback, no-HelpMesh-leak in display name, and unverified-but-set domain falling back to subdomain.

v4.7.0

BYOD part 3 — Settings UI for tenants to register and verify a custom email domain. Closes the loop on Option H: backend (PR #45) + outbound switch (PR #46) + UI for tenants to actually configure it (this PR).

featureAdded

  • apps/web/src/components/settings/EmailDomainSection.tsx — admin-only BYOD configuration card. Three states:
  • apps/web/src/components/settings/CustomDomainTrialBanner.tsx — 14-day BYOD nudge. Shows when tenant.createdAt > 14d ago AND customEmailDomain is null. Dismissable per-browser via localStorage. Gradient blue/indigo card with sparkles icon.
  • Both components wrapped in <RequireRole min="ADMIN"> on the Inboxes tab, mirroring the API-level admin gate (apiHandler requiredRole: 'ADMIN').

1. No domain — domain entry input with apex-domain validation, "Continue" registers with Postmark. 2. Domain registered, not verified — DNS records table (DKIM TXT, Return-Path CNAME, SPF guidance, DMARC guidance) with per-record copy buttons, "Check DNS" button, status badges per record, friendly DNS propagation messaging. 3. Fully verified — green confirmation, "Remove" button to fall back to *.helpmesh.io.

featureChanged

  • apps/web/src/app/api/v1/tenant/route.ts — GET now selects createdAt, customEmailDomain, and emailDomainVerified so the inbox UI can render the trial banner without a second round-trip.
  • apps/web/src/app/(agent)/settings/inboxes/page.tsx — extended the existing /api/v1/tenant fetch to capture createdAt and customEmailDomain, renders the banner + BYOD section above the Connected Channels block.

noteNotes

  • Server-side admin gate is on the four API routes themselves; the client wrapper is UX polish so non-admins don't see the form. Consistent with the rest of settings/*.
  • DNS records are surfaced from POST /api/v1/tenant/email-domain at registration time. Postmark only returns DKIM/Return-Path values once at creation; if the user dismisses without copying, "Remove → re-add" regenerates them.
  • Banner copy uses "trial period ended N days ago" framing — non-billing, signals onboarding time has passed without nagging.

noteStill pending

  • Migrate other outbound paths (CSAT, invite, weekly-digest, dunning, SLA-breach, webhook-disabled, customer-welcome, trial-lifecycle) to use email.resolveOutboundFrom so they all become BYOD-aware.

v4.6.6

BYOD part 2 — outbound code now respects the tenant's verified custom domain. Combined with PR #45 (BYOD backend), agent replies now route through the tenant's own domain when one is configured and verified, falling back gracefully to the previous paths otherwise.

featureAdded

  • packages/channels/src/email/resolve-outbound-from.ts — pure helper that returns { fromAddress, fromName, source } given a tenant + inbox snapshot. Three branches:
  • 9 unit tests covering each branch, precedence, and edge cases (hyphens, empty inbox name, unverified-but-set custom domain).

1. custom-domain — verified BYOD: support@{customEmailDomain} with display name tenant.name. 2. inbox-address — explicit inbox-configured address (legacy / *.inbound.helpmesh.io). 3. subdomain-fallbacksupport@{slug}.helpmesh.io with display name tenant.name (never "HelpMesh", per Option H locked decision).

featureChanged

  • apps/web/src/app/api/v1/threads/[id]/reply/route.ts — replaced inline From construction with email.resolveOutboundFrom(...). Loads customEmailDomain and emailDomainVerified on the thread's tenant include.

noteStill pending (next PR)

  • Settings UI to enter the domain + display DNS records the tenant must publish.
  • 14-day trial banner on /settings/channels?tab=inboxes for tenants past their grace period without a verified domain.
  • Outbound code switch in CSAT, invite, weekly-digest, dunning, etc. — these still use EmailSender.sendReply directly with hardcoded From paths. Should go through the same helper.

v4.6.5

The agent sidebar's "Inbox N" badge wasn't updating when new threads arrived in realtime — only the inbox list itself updated. Verified live in prod: synthetic webhook fired, list flipped from 7 → 8 conversations, sidebar badge stayed at 7.

fixFixed

  • apps/web/src/app/(agent)/layout.tsx now fetches the tenant id from /api/v1/tenant on mount and subscribes to the same private-tenant-{id} Pusher channel via useRealtime. On thread:new or thread:updated, the sidebar refetches /api/v1/analytics so the open-ticket badge stays accurate without a page reload.
  • The fetch logic was extracted into refetchOpenCount so it can be reused both on mount/path change and on realtime events.

v4.6.4

Hotfix — NEXT_PUBLIC_PUSHER_KEY and NEXT_PUBLIC_PUSHER_CLUSTER were missing from the Docker build context, so the next build step couldn't bake them into the client bundle. The agent UI's useRealtime() hook saw process.env.NEXT_PUBLIC_PUSHER_KEY === undefined, fell out of its guard, and never opened a Pusher subscription. ECS task-level secrets only inject at runtime — too late for a Next.js client bundle.

fixFixed

  • Dockerfile builder stage now declares ARG NEXT_PUBLIC_PUSHER_KEY and ARG NEXT_PUBLIC_PUSHER_CLUSTER and ENVs them so next build sees the values and bakes them into the client chunks.
  • .github/workflows/deploy.yml fetches the public Pusher key/cluster from AWS Secrets Manager (helpmesh-prod/pusher-key, helpmesh-prod/pusher-cluster) inside the deploy job and forwards them as --build-arg to the docker build command. Key value is masked in workflow logs.

noteAfter this lands

End-to-end realtime path is fully wired: Postmark inbound → webhook → outbox event → worker fanout → Pusher trigger → client subscription → inbox auto-updates. Verified by sending a synthetic POST to /api/webhooks/postmark and watching a new thread appear in the open inbox tab without a refresh.

v4.6.3

Hotfix — agent inbox subscribed to its tenant's Pusher channel only after a thread was selected. Sourced tenantId from threadDetail?.tenantId, which is null until the user clicks a thread. New threads created elsewhere never pushed live to the inbox list.

fixFixed

  • apps/web/src/app/(agent)/inbox/page.tsx now fetches the tenant id from /api/v1/tenant on mount and passes it directly to useRealtime(), falling back to threadDetail.tenantId if needed. The hook subscribes to private-tenant-{id} immediately on page load, so thread:new and thread:updated events arrive even when no thread is open.

v4.6.2

Hotfix — worker BullMQ Queues were silently connecting to localhost:6379 instead of ElastiCache, so the outbox processor never fanned out OutboxEvents to webhooks/realtime. Postmark inbound emails were creating threads in the DB but not pushing through Pusher to the agent UI, so new threads only appeared on manual refresh.

fixFixed

  • apps/worker/src/processors/outbox.ts, customer-welcome.ts, invoice-pdf.ts were reading REDIS_HOST/REDIS_PORT env vars (which don't exist on prod ECS) and falling back to localhost. ECS provides REDIS_URL (full connection string for ElastiCache TLS). All three now use a shared getQueueConnection() helper that parses REDIS_URL correctly, including rediss:// TLS and embedded credentials.
  • New apps/worker/src/lib/redis-connection.ts exports getRedisConnection() (ioredis instance for Workers) and getQueueConnection() (parsed connection options for Queues). apps/worker/src/index.ts refactored to use the same helper.

featureAdded

  • apps/worker/src/lib/redis-connection.test.ts — 5 unit tests for parseRedisUrl() covering plain redis://, TLS rediss://, embedded credentials, missing port, and the real prod ElastiCache URL shape.
  • Worker package now has test and test:unit scripts (vitest); local apps/worker/vitest.config.ts matches the apps/web pattern. Picks up automatically by pnpm turbo test:unit in CI.

noteOperational follow-up (not in this PR)

  • Worker ECS task definition still missing Pusher secrets. Even with Redis fixed, realtime-fanout.ts will silently no-op until PUSHER_APP_ID, PUSHER_KEY, PUSHER_SECRET, PUSHER_CLUSTER are added to the worker task. They already exist in AWS Secrets Manager (web task uses them) — just need to reference them in worker task too, then force a worker redeploy.

v4.6.1

Hotfix — inbound webhook 500 on payloads with missing/null Headers. Surfaced when the first real Postmark inbound POST hit /api/webhooks/postmark and threw TypeError: e is not iterable from a for...of over payload.Headers. Postmark retried 10 times, no thread created.

fixFixed

  • parseInboundEmail now defensive against missing Headers, ToFull, FromFull, CcFull, Attachments, Date. Falls back to top-level From/To strings when *Full variants absent.
  • extractThreadingHeaders and isAutoReply accept null | undefined for headers.
  • /api/webhooks/postmark wraps parseInboundEmail in try/catch; returns 400 invalid_payload on parse failure with structured log instead of unhandled 500.

featureAdded

  • 8 new tests covering null/missing field paths (packages/channels/src/email/inbound-parser.test.ts). Test count grew from 12 to 20 in this file.

v4.6.0

Inbound email — tenant subdomain routing.

featureAdded

  • Pattern C addressing: anything@{tenantSlug}.helpmesh.io now routes to the matching tenant's inbox. Works alongside existing Pattern A ({slug}@inbound.helpmesh.io) and Pattern B ({inbox}.{slug}@inbound.helpmesh.io). (#39)
  • Reserved subdomains (www, app, api, staging, admin, docs, status, mail, pm-bounces, inbound) excluded from Pattern C so they cannot be hijacked into spoofing a tenant.
  • Test coverage for extractTenantSlug at packages/channels/src/email/inbound-parser.test.ts.

noteOperational dependencies (shipped today, not in this PR)

  • Route53: wildcard MX *.helpmesh.io → inbound.postmarkapp.com. added. Apex Google Workspace MX preserved.
  • Postmark: inbound webhook URL configured to https://helpmesh.io/api/webhooks/postmark.

v4.5.0

Reports PDF Commit 2 of 4 — custom date ranges + per-section toggles.

featureAdded

  • Custom date ranges for the reports PDF: pick a from and to date in the Download PDF panel (or leave blank for the rolling N-day window). Backwards-compat: existing analytics endpoints still accept ?days=N. (#38)
  • Per-section toggles: select which sections (Overview / Agents / SLA / CSAT / Volume) appear in the PDF via checkboxes. All checked by default. Empty section list = all sections (preserves Commit 1 callers). (#38)
  • Shared helper apps/web/src/lib/report-date-range.ts (parseReportDateRange) used by /api/v1/analytics/csat-trends and /api/v1/reports/volume. Default 30d, max 365d.

featureChanged

  • POST /api/v1/reports/pdf body extended with optional from, to, sections. Zod validates that both/neither dates come together. (#38)
  • Worker ReportPdfPayload carries the new fields; loadReportData filters section loaders. Omitted sections return null and the template skips rendering them.
  • PDF header label honors custom range: Reports — 2026-04-01 → 2026-04-26 vs Reports — Last 30 days.
  • Reports page Download PDF button now opens an inline panel (instead of firing immediately). Inline validation: at least one section, both dates or neither, from ≤ to.

v4.4.2

Hotfix #2 — v4.4.1's HUSKY=0 attempt did not work because husky's binary doesn't exist in node_modules when devDependencies are skipped, so the shell can't even invoke the script (sh: 1: husky: not found).

fixFixed

  • Worker Dockerfile: moved ENV NODE_ENV=production to AFTER pnpm install. Mirrors the alpine deps stage's pattern — install with full deps (including husky), then set production env for runtime. prepare script's husky invocation succeeds because husky is now actually installed.

Production was still on v4.3.1 — neither v4.4.0 nor v4.4.1 image was pushed because both builds failed before push. v4.4.2 is the first image to actually deploy if its build succeeds.

v4.4.1

Hotfix — v4.4.0 prod deploy failed on the worker Docker build because the Debian-based worker stage set NODE_ENV=production before pnpm install, which stripped the husky devDependency, but the root package.json prepare script still tried to invoke husky and failed with husky: not found.

fixFixed

  • Worker Dockerfile: set HUSKY=0 on the pnpm install step so the root prepare script becomes a no-op. All other install scripts (Prisma engines, argon2 native build) still run normally.

Note: v4.4.0 tag exists but its image was never built/pushed because the build failed before that step. Production was unchanged. v4.4.1 is the first version of the PDF export feature that actually deploys.

v4.4.0

On-demand PDF export for reports (Commit 1 of 4 of the reports PDF feature).

featureAdded

  • Reports PDF export (/reports page) — ADMIN+ users can click "Download PDF" to generate a branded PDF of all 4 reports tabs (Overview, Agents, Labels, Volume). Tenant logo + brand color applied. (#37)
  • A.2 worker queue pattern: POST /api/v1/reports/pdf enqueues a report-pdf-generate job; UI polls GET /api/v1/reports/pdf/[jobId] every 2s; auto-downloads via 15-minute signed S3 URL on completion.
  • Per-tenant rate limits: 1 concurrent PDF job + 5 jobs/hour. Both enforced via Redis (lock + hourly counter).
  • S3 path: tenants/{tenantId}/pdf-reports/{reportId}.pdf with PDF metadata title {Tenant Name} — Reports — Last N days.

featureChanged

  • Worker Docker image: switched from node:22-alpine to node:22-bookworm-slim so Chromium can run (@sparticuz/chromium-min ships glibc binaries only). Web image stays alpine. Worker image grew ~+250MB to include Chromium system deps. (#37)
  • `StorageService.buildKey` type union extended with 'pdf-reports'.
  • `@helpmesh/web` added bullmq dep for direct queue access from API routes.

noteAction item (outside this repo)

  • Worker ECS task memory should be raised from 1GB → 2GB before this version handles real load. Chromium consumes ~150MB per render plus baseline Node memory.

noteComing in subsequent PRs

  • Per-section toggles + custom date ranges (Commit 2)
  • Email-the-PDF flow (self / tenant agents) (Commit 3)
  • Scheduled reports + new ScheduledReport schema (Commit 4)

v4.3.1

Patch — risk-watch audit on PR #34's settings consolidation. 5 of 6 risk-watch items verified as non-issues; 1 minor inefficiency fixed.

fixFixed

  • KB article editor — removed form.body from the Tiptap init useEffect deps array. Tiptap's onUpdate set form.body on every keystroke, so the effect was re-firing per character. The tiptapInitialized.current guard prevented re-mount, so this was wasted effect cycles, not a correctness bug. Added inline comment documenting the intentional dep omission. (#36)

noteVerified (no code changes needed)

  • Settings redirects from PR #34: 24 legacy paths, 24 redirects in next.config.mjs. Verified 1:1 coverage by sorted diff.
  • Tab state retention in 9 grouped settings pages: no tab page uses useSearchParams, router.replace with queries, or window.history. Hidden-div pattern works as designed.
  • Phone input default country: defaultCountryFor(locale) correctly maps ar→AE, fr→FR, else US. Locale flows from customer.locale.
  • Custom field metadata merge in customer edit: existingMeta spread first, only form-owned keys (whatsapp, company, companyAddress) are set or deleted. Custom fields preserved across saves.
  • Onboarding checklist visibility: gated on strict totalThreadCount === 0. Defense-in-depth check inside the component too.

v4.3.0

Phase 6 Part 3 polish pass. UI-only — no schema changes, no new endpoints.

featureAdded

  • System Health admin page (/admin/system-health) — read-only status banner, database card with row counts, memory & runtime card. 30s auto-refresh + manual refresh. Reads existing /api/v1/system/health endpoint. Linked from super-admin nav. (#35)
  • Privacy Policy (/legal/privacy) — controller + processor disclosure, GDPR/PDPL/CCPA rights, Mumbai data residency, 72h breach notification. Marked "Draft — pending legal review" with [YOUR LEGAL ENTITY] markers. (#35)
  • Terms of Service (/legal/terms) — acceptable use, IP, suspension, fees, liability cap. Same draft banner pattern. (#35)
  • Cookie Policy (/legal/cookies) — strictly-necessary / analytics / marketing tables (provider names left blank for counsel). (#35)
  • Data Processing Addendum (/legal/dpa) — processor obligations, sub-processor authorization, audit cadence, return/deletion. (#35)
  • Reports date-range expanded from 3 presets (7/30/90 days) to 6 (7/30/60/90/180/365) plus a Year to date option computed at click-time. (#35)

featureChanged

  • PWA manifest rewritten — start_url: /home (was /inbox), theme_color: #1d6cff (was #0066CC), background_color: #fafaf9. Removed bogus pixel-size declarations on SVG icon entries (one any + one maskable). Added scope: '/'. The <meta name="theme-color"> tag in the root layout was updated to match. (#35)
  • Customer portal: header gains a "New ticket" button when tickets exist (was only in empty state). Search/filter-empty state gets "Clear search" + "New ticket" CTAs instead of a bland dead-end. (#35)
  • Legal nav: added Cookies link. (#35)

v4.2.0

Soft Studio design language rolled out across the agent app, plus structural improvements to the core flows.

featureAdded

  • First-run onboarding checklist on /home for fresh tenants: 4 steps (connect inbox, install widget, invite teammates, create KB article) with state derived from existing list endpoints. Disappears once the first ticket arrives. (#33)
  • 3-pane inbox context rail at xl+ (≥1280px) — persistent right rail with requester info, ticket meta, SLA, AI, labels, linked issues, custom fields, customer timeline. Below xl renders as a slide-in overlay. New ] keyboard shortcut to toggle (mirrors [ for the left sidebar). Preference persists per-agent via localStorage. (#28)
  • Role-aware home dashboard — branched metrics + attention widgets per tenant role. VIEWER sees historical KPIs; AGENT sees personal queue + my-only attention; TEAM_LEAD sees team aggregates; ADMIN/OWNER sees team + a personal "Your queue" subsection. New /api/v1/analytics/historical endpoint backs the viewer view. (#29)
  • Customer detail edit modal with phone + WhatsApp (E.164 country pickers via react-phone-number-input), company name + multi-line address. Shared <CustomerEditModal> component replaces two inline duplicate modals. Customer detail surfaces a new Company card. (#31)
  • Empty-state CTAs across the app — every list-style page (inbox, customers, KB, settings sub-pages) now has a meaningful "first action" launcher. (#33)
  • RTL direction switching — root layout now actually flips dir based on the NEXT_LOCALE cookie (was previously broken — read from a non-existent route param). Dev-only ?dir=rtl URL override for previewing without changing account locale. New logical-aware animate-slide-in-end animation that respects direction. (#32)

featureChanged

  • Soft Studio design tokens — palette swap to warm #fafaf9 background, brighter #1d6cff brand, refined neutrals on the stone scale, layered low-spread shadows. Sidebar tokens for both light and dark mode. Tailwind preset's boxShadow utilities now read from CSS vars (was hardcoded; broke dark-mode focus ring). (#26)
  • Logical-properties sweep on core flows — ~50 mechanical conversions (pl-/pr-ps-/pe-, etc.) across login, signup, agent shell, home, inbox, customer detail, and KB. Direction-implying icons (back arrows, pagination chevrons, panel toggles) flip via rtl:rotate-180. (#32)
  • KB article editor restructured into 3 tabs (Content / SEO / Settings). Tiptap stays mounted across tab switches so editor state is preserved. (#30)
  • Customer detail pagestatus_change events now show the actual new status; avatar initials fall back to email-local-part then customer ID; tickets card shows true total via customer._count.threads and links to the inbox filtered by customerId when there are more than the loaded 50; activity timeline gained "Show all N events" toggle. (#31)
  • Settings consolidation (Path C) — flat 21 routes → 9 grouped pages with internal tabs (Workspace, Channels, People, Content, Automation, AI, Developer, Notifications & Audit, Billing). Old URLs 302-redirect to the new tabbed URL via next.config.mjs. Tab state retains across switches via the hidden-div pattern. ADMIN-only tabs get tab-level role gating with a friendly inline lockout. (#34)
  • Inbox welcome variant for fresh tenants on the empty thread-detail pane: "Welcome to HelpMesh" with Connect inbox / Configure widget CTAs. (#33)

fixFixed

  • 151 pre-existing lint errors → 0 across apps/web/src/. Mostly mechanical: consistent-type-imports autofix on 85 API routes, unused-imports cleanup on 33 files, dead eslint-disable comments removed, eqeqeq rule relaxed to { null: 'ignore' } (every flagged != null was the intentional "null OR undefined" idiom). (#27)
  • Inbox context rail previously vanished below 1024px with no fallback and no toggle button to bring it back. Now overlays below xl with a backdrop. (#28)
  • Home dashboard charts — Volume gradient + axis colors switched from hardcoded hex values to CSS-variable tokens so dark mode works. (#29)

noteVersioning

  • Root, 3 apps, 13 packages aligned to 4.2.0.

v4.1.0

featureChanged

  • Version bump to 4.1.0 across all workspace packages (root, 13 packages, 3 apps). Sub-packages aligned from 0.1.0 to monorepo version. VERSION file synced from v3.9.0.

v4.0.0-alpha.21

featureAdded

  • /home landing view: greeting header, four KPIs (open, new today, awaiting reply, avg first response), three "needs your attention" columns (SLA at risk, unassigned urgent, awaiting my reply), 7-day volume chart, 7-day CSAT average.
  • Live SLA countdown in the thread header (mm:ss / hh:mm / d h, green / amber / red / breached).
  • Snooze threads with presets (1h, 3h, tomorrow 9am, next Monday 9am) + custom datetime. Snoozed threads auto-rejoin the inbox on wake.
  • AI assist menu in composer: draft from scratch (friendly / professional / concise tones), improve, shorten, translate to 7 languages.
  • Real in-app notifications: new InAppNotification model, worker fanout from OutboxEvent (assigned, customer-replied, SLA breach), polling bell with unread count, mark-read + mark-all-read.
  • Customer 360° profile at /customers/:id: contact details, custom fields, activity timeline, full tickets list, stat pills.
  • Reports Overview now shows real charts for Volume, By Channel, and Top Labels (replaced "coming soon" stubs).
  • New API routes: /api/v1/ai/transform, /api/v1/analytics/attention, /api/v1/notifications (+ mark-read, mark-all-read).

fixFixed

  • /inbox/:threadId deep links now render the inbox with the thread preselected instead of redirecting to /inbox. Browser back/forward moves between threads. 404/403 shows a toast and clears selection.

featureChanged

  • Post-login landing is now /home (was /inbox). Sidebar gets a Home entry; command palette adds G H shortcut.
  • Snoozed threads are hidden from the default inbox list via a server-side filter; a "Show snoozed" toggle reveals them.

noteDatabase

  • Additive migration: InAppNotification table, per-tenant/per-user, indexed on (userId, readAt, createdAt desc). Cascade on tenant/user deletion.

v1.8.0

featureAdded

  • WCAG AA accessibility: skip-to-content, ARIA landmarks, keyboard navigation, focus-visible, reduced motion
  • 56 integration tests across db, core, auth, web packages

v1.7.0

featureAdded

  • Test suite: 56 tests across 4 packages (db, core, auth, web)
  • Vitest configs for all packages

v1.6.0

fixFixed

  • Phase 7 QA: lint cleanup, 20+ unused imports removed, zero legacy tokens

v1.5.0

featureAdded

  • ISO 27001 compliance: Consent tracking, GDPR data export, right to erasure
  • Password change with session invalidation
  • System health + activity monitoring (admin-only)
  • useApi hook for consistent error handling

fixFixed

  • actorId wired in audit log writes
  • optimizePackageImports for performance

v1.4.0

featureAdded

  • Widget: i18n (20 strings), ARIA live regions, design token alignment (5.29KB gzip)

v1.3.0

featureAdded

  • Customer Portal: ticket list, new ticket, thread detail with message bubbles

v1.2.0

featureAdded

  • Knowledge Base: article editor restyled, public portal token fixes
  • All 11 settings pages restyled with HelpMesh design system
  • Auth flow: forgot-password, reset-password pages
  • Inbox wired to real API with Pusher realtime

v1.1.0

featureAdded

  • Inbox wired to real API (threads, messages, composer, realtime)
  • Session-aware layout with useSession/signOut
  • Logo integrated (72px on auth pages)
  • All 11 settings pages restyled

v1.0.0

featureAdded

  • Design system: 18 shared components in packages/ui
  • Tailwind preset with full HelpMesh token system
  • App shell (64px nav, 48px top bar, command palette)
  • Tri-column inbox with 50 demo conversations
  • Settings shell with form patterns
  • Reports dashboard with Recharts
  • Onboarding stepper
  • Kitchen sink verification page
  • Branding guidelines + visual design spec

v0.9.0

featureAdded

  • Phase 3: integrations, 2FA, security, Slack, all settings
  • Complete documentation suite
  • AWS infrastructure (Terraform)

Want changelog updates?

One email when each release ships. No spam.