Skip to content

feat: agent report-access program (people/role profiles, role-aware sending, agent unified link) + viewer timezones - #258

Merged
important-new merged 107 commits into
InspectorHub:mainfrom
important-new:feat/people-role-profiles
Jul 20, 2026
Merged

important-new merged 107 commits into
InspectorHub:mainfrom
important-new:feat/people-role-profiles

Conversation

@important-new

Copy link
Copy Markdown
Contributor

Summary

Lands the agent report-access program end to end, plus a viewer-timezone / date-hygiene pass across public surfaces. Based directly on the current main (106 commits, one milestone).

Spec 1 — People & role profiles

  • New inspection_people + contact_role_profiles tables. Inspections link to contacts through role profiles instead of the legacy client_* / *_agent_id columns (now dropped).
  • Admin Roles tab in /contacts; editable People section on the inspection detail.
  • /api/role-profiles CRUD (system profiles protected) and /api/inspections/:id/people add/list/remove.
  • Services refactored to resolve client/agent via inspection_people (transactional email, CSV export, analytics, publish/hub, concierge, contact linking).

Spec 2 — Role-aware sending

  • Recipient discriminator (kind + role profile) end to end; resolveRecipients fans out to every receivesReport role.
  • One automation_log per recipient (recipient_role_key) with recipient-keyed idempotency; SMS consent-gate fails closed on role-lookup error.
  • Send report modal (by role / by person); parameterized multi-recipient send endpoint; /complete delivers via the automation engine.

Spec 3 — Agent unified link

  • Single-use magic-login mints a core-native agent JWT (bypasses portal); /agent-login dual-mode (email+password primary, magic-link fallback).
  • Report landing branches registered (magic-login) vs unregistered (signup CTA); find-my-report redeem lands agents on /agent-dashboard.
  • Agent profile round-trip; per-agent timezone override + per-company referral dates; focused report-only chrome for the agent report landing.

Timezones, theme & date hygiene

  • Per-agent + per-tenant timezone preferences with an effective-zone hint and one-click "use my browser timezone"; onboarding pre-fills the detected zone.
  • Consistent theme control across agent, client-portal & editor surfaces.
  • App-wide raw-ISO date cleanup through the shared formatters. Tenant-less public pages (observe / verify / concierge / agreement-printable) render dates in the viewer's own browser timezone with a "Times shown in {zone}" control.

Migrations

0013 people tables · 0014 drop legacy inspection people columns · 0015 automations recipient discriminator (+ data migration) · 0016 automation_logs.recipient_role_key · 0017 recipient-scoped log uniqueness.

Testing

Full branch gate green: type-check, lint (incl. tenant-scope / dead-code / snapshot-drift), test:unit (3482 passed), test:web (1387 passed), build. Each spec shipped with its own E2E (people role-profile CRUD, role-aware delivery, agent unified link).

🤖 Generated with Claude Code

important-new and others added 30 commits July 18, 2026 20:04
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…yer + listing agent)

createInspection now mirrors the primary client, buyer's agent, and
listing agent into the inspection_people join table via
PeopleService.addPerson, alongside the legacy clientContactId /
referredByAgentId / sellingAgentId columns (kept until a later
migration retires them). The write is best-effort — a people-write
failure never rolls back an already-committed inspection row.

Refactored the existing client soft-upsert block to capture the
resolved/created contact id instead of doing a second lookup.
Bumped the file-size ratchet baseline for the grown service file.
The legacy single-service booking path in BookingService.fulfillBooking
inserts inspections directly (not via InspectionCoreService.createInspection,
which already got the Task 7 people-write). Mirror client + buyer_agent into
inspection_people for that direct-insert path, non-fatal like Task 7.
ConciergeService.createBooking inserts inspections directly (not via
InspectionCoreService.createInspection, which already got the Task 7
people-write). Mirror the resolved agent-tenant-link contact into
inspection_people (buyer_agent), non-fatal like Task 7. No client contact
id is resolved anywhere in this flow, so only the agent role is written.
InspectionRequestService.create inserts inspections directly for every
sub-inspection in a request (not via InspectionCoreService.createInspection,
which already got the Task 7 people-write). Mirror the agent referral
(input.referredByAgentId) into inspection_people (buyer_agent) for each
created sub-inspection, non-fatal like Task 7. No resolved client contact id
is available in this service, so only the agent role is written.
addSubInspection() is left untouched — its input carries no agent/client
contact id at all, so there is nothing to confidently resolve there.
…ng inspections (multi-service)

Task 7b wrote the client to inspection_people only for the legacy
single-service directInsertInspectionId, but bookingClientContactId is
linked to ALL booking inspections (allInspectionIds) including
multi-service sub-inspections created by InspectionRequestService.create,
which writes only buyer_agent and never the client. Multi-service
booking sub-inspections therefore had no client inspection_people row.

The client write now loops allInspectionIds; buyer_agent stays scoped to
directInsertInspectionId (multi-service owns its own buyer_agent write).
…on_people

Both methods now source client/buyer-agent/listing-agent from
PeopleService.listPeople (the inspection_people join) instead of the legacy
inline clientName/clientEmail/clientPhone columns + fetchAgentsById lookups.
Return shapes are unchanged; agent .id stays the CONTACT id (p.contactId),
matching the old contract, not the inspection_people join-row id.

fetchAgentsById is removed — these two methods were its only callers.

Updated the three legacy specs (inspection-recipients, inspection-people-card,
inspection-hub) to seed inspection_people rows alongside the legacy columns,
since they now drive what getPeopleCard/getRecipientList actually read.
…eService

The report-ready auto-send on inspection completion resolved its recipient
from the legacy inspection.clientEmail/.clientName columns (dropped in Task
13). Resolve the primary client once via PeopleService.getPrimaryClient
instead, sourcing the token issuance, email send, and admin-notification
metadata from the join. Behavior-preserving: same email goes out, now sourced
from inspection_people.

Also refreshes the file-size ratchet baseline (publish.ts grew slightly;
inspection-core.service.ts's prior shrink was never re-baselined).
…ient via PeopleService

The send-report-pdf resend endpoint fell back to inspection.clientEmail when
no toEmail override was supplied (dropped column, Task 13). Fall back to
PeopleService.getPrimaryClient instead, and drop the "inspection.clientEmail"
wording from the 400 error message since that column no longer exists as a
concept the caller can set directly.
…a PeopleService

POST /api/invoices/request-payment resolved the recipient email and the
invoice's clientName snapshot from inspection.clientEmail/.clientName
(dropped columns, Task 13). Resolve the primary client once via
PeopleService.getPrimaryClient and source both fields from the join.
Behavior-preserving.

Updates the existing invoice-request-payment.spec.ts fixture to seed the
primary client through inspection_people (mirroring what the create flow now
writes) instead of the legacy inline columns, since the route no longer reads
them.
…nt via PeopleService

clientEmailForInspection / clientNameForInspection (consumed by messages.ts's
client-attribution and portal-notification deep-link) read the legacy
inspections.clientEmail/.clientName columns (dropped in Task 13). Both now
resolve via PeopleService.getPrimaryClient against the inspection_people join
instead. messages.ts call sites are unchanged (same signatures).

Updates the two existing MessageService test fixtures
(client-message-routes.spec.ts, message.service.spec.ts) to seed the primary
client through inspection_people instead of the legacy inline columns, since
these helpers no longer read them.
scheduleReminderLog/scheduleFollowupLog resolved the automation-log
recipient off inspection.clientEmail, one of the legacy denormalized
columns being dropped (Task 13). Both now resolve the recipient via
PeopleService.getPrimaryClient (inspection_people join), tenantId
already in scope. Adds coverage seeding inspection_people without the
legacy columns.
AgreementService.findOrCreate's default-signer branch (no opts.signers)
resolved name/email off insp.clientName/.clientEmail, legacy denormalized
columns being dropped (Task 13). Now resolves via
PeopleService.getPrimaryClient (inspection_people join); an explicit
opts.signers[0] still takes precedence exactly as before.

Two shared agreements test fixtures (agreement-signers-setup.ts,
inspection-sign-unification-setup.ts) seeded only the legacy columns
with no inspection_people row; backfilled a matching primary-client
contact so the many specs asserting the default signer is "Jane" /
"jane@test.com" keep passing (setup-only change, no assertions
weakened).
…eopleService

POST /api/inspections/:id/agreement-requests resolved the default
recipient email/name off inspection.clientEmail/.clientName, legacy
denormalized columns being dropped (Task 13). Now resolves via
c.var.services.people.getPrimaryClient; an explicit body.email still
overrides exactly as before.

Updates two fixtures whose buildApp() didn't wire a people service and
whose "happy path" relied on the legacy columns with no
inspection_people row backing them (setup-only changes; the
"no client resolvable" regression test now removes the
inspection_people row instead of nulling the legacy column, same
assertions).
…ia PeopleService

approveByInspector gated on and emailed insp.clientEmail, a legacy
denormalized column being dropped (Task 13). Now resolves via
PeopleService.getPrimaryClient (inspection_people join).

Note: ConciergeService.createBooking does not itself write a
client-role inspection_people row (only buyer_agent — see
concierge-people.spec.ts), so the existing happy-path fixture needed a
manually-seeded primary-client contact to keep passing (setup-only;
flagged as a concern in the task report since it means concierge
bookings need a client-role person written somewhere before this
approval email will fire in production).

Bumps the file-size baseline for concierge.service.ts (+3 lines from
the conversion).
…pleService

ensureClientContact read inspection.clientEmail/.clientName/.clientPhone
off an inspection-shaped argument to dedupe-or-create a contact — those
legacy denormalized columns are being dropped (Task 13). Changed
signature to (dbRaw, tenantId, inspectionId): it now backfills
inspections.client_contact_id from the EXISTING contact referenced by
the inspection_people primary-client join (PeopleService.
getPrimaryClient). The dedupe-by-email/create-new-contact logic is
gone — a primary-client join always already points at a real contacts
row, so there is nothing left to create. Smaller-blast-radius choice:
the one caller (server/api/sms.ts attest route) already had the
inspection id in scope, so it now passes inspectionId directly instead
of the full inspection row.

Updates the two affected fixtures: the dedicated ensureClientContact
spec is rewritten around the new signature/behavior, and
sms-api.spec.ts's "free-typed client" attestation test — which
exercised the now-retired create-from-denormalized-strings path — is
adapted to seed an inspection_people primary-client join instead
(same assertions: 200, clientContactId backfilled, consent recorded).
…eople row

ConciergeService.approveByInspector (Task 9b) resolves the client via
PeopleService.getPrimaryClient (inspection_people join), but createBooking
never wrote a client contact or inspection_people row -- only the legacy
inspections.clientEmail/clientName/clientPhone columns. Every concierge
reviewer-mode approval would throw BadRequest once those legacy columns
are dropped (Task 13).

createBooking now upserts the client as a contact (ContactService.
upsertClientContact, same idempotent match booking.service/core.ts use)
and mirrors it into inspection_people as `client`, alongside the existing
buyer_agent mirror. Non-fatal, same as the existing people-write block.

Updated concierge-service.spec.ts's approveByInspector test, which had
manually seeded a client contact + inspection_people row to work around
the gap this fix closes (now redundant and would collide with the
new auto-upsert). Extended concierge-people.spec.ts to assert the client
role is written and getPrimaryClient resolves it.
…ng agent via inspection_people

Adds a tenant-scoped helper that resolves the single contact occupying a
given role key (buyer_agent, listing_agent, ...) on an inspection by
joining inspection_people -> contact_role_profiles, replacing the pattern
of reading inspections.referredByAgentId / .sellingAgentId directly.
Foundational helper for the agent-attribution query rewrite (Task 9c).
…eople

POST /api/inspections/:id/share-agent now resolves the linked buyer's
agent through PeopleService.contactIdForRole(tenantId, id, 'buyer_agent')
instead of reading inspections.referredByAgentId directly. Same two-step
error semantics preserved (400 "No agent linked" vs "Agent has no email
on file"). Bumps the file-size baseline for the +4 net lines.
…spection_people

listReferrals, accessToInspection, listRecommendationsForAgent, and
referralsByDay in server/services/agent/referral.ts now source the
buyer-agent contact id from inspection_people (role buyer_agent) instead
of inspections.referredByAgentId. contact_role_profiles is joined BEFORE
inspection_people in every site so the join stays scoped to buyer_agent
only — joining inspection_people first would fan out over every role
(client, co_client, listing_agent, ...) an inspection carries.

Updates the agent-service-listings.spec.ts fixture to also seed the
matching inspection_people rows (setup only, no assertions weakened) and
adds a fresh test proving attribution still resolves with the legacy
column NULL. Adds tests/unit/agents/agent-referral-people.spec.ts
covering accessToInspection, listRecommendationsForAgent, and
referralsByDay directly against a real DB (previously only exercised
through mocks at higher layers).

Flags one latent multiplicity risk in referralsByDay: if an inspection
ever carried more than one buyer_agent inspection_people row, it would be
double-counted there (no downstream dedup) — not reachable via current
create paths (single buyer_agent per inspection).
…t via inspection_people

GET /api/agent/my-reports and GET /api/agent/leaderboard in
server/api/agent.ts no longer filter/group on
inspections.referredByAgentId directly:

- my-reports resolves the set of inspection ids via a two-step query
  (inspection_people join contact_role_profiles filtered to buyer_agent)
  before selecting flat inspection rows by id, preserving the
  `db.select()` all-columns shape.
- leaderboard's groupBy aggregate now joins contact_role_profiles before
  inspection_people (scoped to buyer_agent) then leftJoin contacts,
  keeping the existing agentId-not-null post-filter as a no-op safety net.

Adds tests/unit/agents/agent-reports-leaderboard.spec.ts (no prior
coverage existed for either route) seeding inspections with the legacy
column NULL and only inspection_people populated.
…via inspection_people

GET /api/metrics topAgents no longer groups/joins on
inspections.referredByAgentId. Joins contact_role_profiles (scoped to
buyer_agent) before inspection_people, then leftJoin contacts — same
single-column groupBy(inspectionPeople.contactId) as before. The old
"referredByAgentId is not null" filter becomes "inspectionPeople.contactId
is not null", which is now implicit but kept explicit for clarity/parity.

Adds tests/unit/metrics/metrics-top-agents-people.spec.ts (no prior
coverage existed for this route) seeding inspections with the legacy
column NULL and only inspection_people populated.
…on_people

Doc comments on getAgentReferralFilter, listReferrals, and
accessToInspection still described the association predicate in terms of
inspections.referredByAgentId after the Task 9c join rewrite. Updated to
describe the actual current source (inspection_people buyer_agent
contactId) — no code change. Bumps the file-size baseline for the +2 net
comment lines.
…ent via PeopleService

Task 9c (people-role-profiles) — GET /api/admin/sms/consent and
ensureClientContact's "already linked" fast path both read
inspections.clientContactId directly. Convert both to resolve the client
contact via PeopleService.contactIdForRole('client') (inspection_people is
the source of truth), keeping the legacy column write as a best-effort
backfill for other not-yet-converted readers. Fixed two sms-api.spec.ts
fixtures that seeded clientContactId without a matching inspection_people
row.
…ry client via PeopleService

Task 9c (people-role-profiles) — InspectionCoreService.getInspection's single-
inspection response and listInspections' list response both inlined
clientName/clientEmail(/clientPhone) straight off the legacy inspections
columns. getInspection now resolves PeopleService.getPrimaryClient per
inspection (single row, cheap); listInspections uses one LEFT JOIN through
inspection_people(client role)+contacts (contact_role_profiles filtered
before inspection_people, mirroring the join order in api/metrics.ts) to
stay N+1-free across the page. Hard cutover, no legacy-column fallback,
matching the pattern already used by invoices.ts/agreements.ts/publish.ts.
important-new and others added 21 commits July 19, 2026 22:46
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSKVuFcvPArEfoDj76uQ7A
…ation guard)

- /api/agent/login summary 'Agent password login' (3 words) failed the
  route-metadata 4-12-word rule -> 'Authenticate an agent by email and password'.
- sso-handoff.schema.ts comment dropped the literal
  server/portal/integration.routes.ts path string flagged by the SaaS-portal
  isolation guard.
- Regenerate MCP snapshot for the changed summary.

Both only surface in the full test:unit suite, not the per-task filters.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Spec 3 Task 7 — GET /api/portal/:tenant/redeem now branches on
findGlobalAgentByEmail(c.env.DB, email): a global agent account mints an
agent JWT (__Host-inspector_token, no tenantId, mirroring
server/api/agent/login.ts's mint exactly) and returns { email, agent: true },
NEVER the client __Host-portal_session cookie. The client/co_client redeem
path is byte-for-byte unchanged. The RR loader (app/routes/public/portal-auth.tsx)
now redirects agent redemptions to /agent-dashboard instead of the client hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSKVuFcvPArEfoDj76uQ7A
…shift

Task 5 added the DUMMY_HASH export + agent-login handler above the invite
flow in auth.service.ts, shifting three already-baselined tenantInvites-by-token
queries (invite acceptance — the token IS the credential, no tenantId exists
pre-acceptance) from lines 128/184/213 to 133/189/218. Same safe queries,
new line numbers. lint:tenant-scope lives only in the full lint, not test:unit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a match-agnostic guidance line to the client portal's request-link
success state so a client who mistyped their email (or used a different
one than their inspector has on file) isn't left waiting silently for an
email that never arrives. Renders inside the existing single success
state — no new "not found" branch — so anti-enumeration is preserved:
the copy never reveals whether a match was found.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSKVuFcvPArEfoDj76uQ7A
Add tests/e2e/agent-unified-link.spec.ts (Spec 3 Task 8), modeled directly
on role-aware-sending.spec.ts's harness/helpers, covering scenarios 1-4 of
the agent-unified-link plan: registered-agent report-link conversion to an
authenticated /agent-dashboard session, unregistered-agent signup
conversion with the welcome highlight, /agent-login password + magic-link,
and the security guarantee that a report token alone never authenticates
/agent-dashboard (plus single-use code enforcement). Scenario 5 (SaaS
Google-OAuth) is skipped with a TODO per the task brief.

Writing the E2E surfaced two real gaps in the already-merged Spec 3 work
that this commit also fixes, since they blocked MUST-PASS scenarios:
  - agent/signup.tsx's action never forwarded the agent-signup API's
    Set-Cookie into the browser session, so a converting agent bounced
    back to /login instead of landing on /agent-dashboard authenticated.
    Fixed to mirror agent/login.tsx's existing pattern, and reordered the
    action's redirect-target precedence so a report-path returnTo's
    welcome-highlight redirect isn't permanently shadowed by the API's
    static `/agent-dashboard` redirect.
  - the agent-login-link email trigger was never registered in the email
    template registry, so EmailService.sendAgentLoginLink always threw
    "Unknown email template trigger" and the magic-link email never sent.
    Added the missing registry descriptor (updates two hardcoded registry
    count assertions + the file-size baseline accordingly).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSKVuFcvPArEfoDj76uQ7A
Spec 3 introduced 8 unused export surfaces the full-lint dead-code gate flags:
- agent/login.ts: drop the redundant `export default` (the named
  agentLoginRoutes is what server/index.ts mounts).
- agent.schema.ts RecommendationRowSchema, send-report.schema.ts
  SendReportSkippedSchema, portal-exchange.ts EMPTY_STATUS_OVERVIEW: un-export
  (used only within their own module).
- send-report.schema.ts SendReportRecipient/SendReportBody types +
  report-context.ts AgentReportContextApi type: delete (no consumers).

Dead-code gate OK (0 new); full lint + type-check green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…review)

The whole-branch review flagged RequestMagicLoginParams.tenantId as a dead
field whose comment falsely claimed it was recorded in the audit entry — the
function body never reads it (grant.tenantId from resolvePortalAccess is the
sole authorization boundary). Remove the field + its call-site pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An agent opening their report link saw the full client hub shell — all
client-only tabs (Overview/Agreement/Payment/Progress/Messages/Repair
Request/Documents), Sign out, and the in-report Repair Request / repair-list
actions — even though the server already forces section='report' and gates
every other section (so they were dead, confusing chrome). Add an agentMode to
InspectionHub (driven by the same agentReport?.kind==='agent' flag the
AgentReportActions CTA uses) that hides the tab bar + Sign out, and a
hideClientActions flag on ReportView that drops the client-transaction buttons.
Report-viewing affordances (Print, Download PDF) and the 'Go to my workspace' /
signup CTA stay. Pure UX polish — no security boundary change (the server
enforcement is unchanged). +InspectionHub.test.tsx (4 cases); ReportView
file-size baseline 762->768.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Agent dashboard referral dates rendered as raw ISO timestamps
(2026-07-20T00:27:12.605Z). Resolve them through the shared formatter with
a zone label, and add a timezone-resolution chain fit for global agent
users (who span many tenants and have no single tenant tz):

  1. the agent's personal timezone override (Settings -> Display), when
     set, applied to every row;
  2. else each row's owning-tenant timezone (tenant_configs.default_timezone,
     the same source as branding.defaultTimezone);
  3. else 'UTC' -- also the tenant's own unconfigured fallback, so an agent
     with no override sees exactly what that company would show.

- referral.ts: left-join tenant_configs, surface tenantTimezone per row.
- agent profile GET/POST + service: read/write users.timezone (IANA,
  validated via isValidTimeZone; empty string clears the override).
- agent-layout: load the agent's tz into a route-level session context
  (useAgentTimeZoneOverride) -- the agent-portal analogue of
  useSessionContext.
- settings-profile: Timezone picker card ("Use each company's timezone"
  default) that saves on change.
- dashboard: render each referral date via formatInspectionDateTime in the
  resolved zone instead of the raw ISO string.

Tests: referral-service tenantTimezone (configured + UTC fallback),
dashboard resolution (agent override wins; per-row tenant tz otherwise),
settings save-timezone (set + clear).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSKVuFcvPArEfoDj76uQ7A
…imezone"

Timezone pickers gave no signal about what the "inherit / use each company"
default resolves to, and never surfaced the viewer's own zone — below the
baseline set by mainstream field-service tools (Housecall Pro / Jobber /
ServiceTitan), which display the active zone and pre-detect the browser's.

- A: the tenant per-user picker's default option now names the company zone it
  resolves to, e.g. "Use company timezone (America/New_York)" (from
  session-context branding.defaultTimezone), instead of a bare label.
- B/C: a shared <BrowserTimezoneHint> under both the tenant and agent pickers
  shows "Your browser timezone is (UTC-06:00) America/Chicago · Use this",
  offering a one-click adopt. It appears only when the detected zone differs
  from what's already in effect, and reads the zone AFTER mount so there is no
  SSR/hydration mismatch. The rendering fallback stays UTC (deterministic,
  SSR-safe) — the browser zone is a suggestion, never an automatic default.

Tenant picker stays uncontrolled (Conform reads it on submit); "Use this"
writes the DOM value + fires a native change so Conform sees the edit. The
agent picker is controlled and saves on change.

Tests: BrowserTimezoneHint (offers when differing, hides when equal, shows for
the agent's empty "use each company" value, calls onUse with the detected
zone). Live-verified in Chrome on both surfaces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSKVuFcvPArEfoDj76uQ7A
Rec D. The "Set your timezone" onboarding step dropped users on the workspace
page with the company timezone still on its silent UTC default — below the
baseline set by mainstream field-service tools, which detect the zone at setup.

- The onboarding step now deep-links with a ?setup=timezone marker.
- On that arrival, when the tenant is still on the default UTC, the workspace
  page pre-selects the browser-detected zone, scrolls to the field, and shows
  "Detected from your browser. Save to confirm, or pick another." — the save
  bar prompts to confirm; nothing persists until the admin saves. Detection
  runs after mount (no hydration mismatch) and only once; normal visits (no
  marker) are unchanged and just carry the shared browser-timezone hint.
- The picker stays uncontrolled (Conform reads it on submit); the pre-fill
  writes the DOM value + fires a native change so Conform records the edit.

Tests: onboarding-progress href now carries the marker. Live-verified in
Chrome: onboarding step -> workspace pre-fills Asia/Shanghai -> Save persists
(tenant_configs.default_timezone), completing the step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSKVuFcvPArEfoDj76uQ7A
…ditor surfaces

The auto/light/dark/field theme is applied on every page (all render inside the
cookie-backed <html data-color-scheme> tree), but the SWITCH control only lived
in the tenant sidebar and, in a different bespoke form, the editor header —
several high-traffic surfaces had no way to change it. Unify on the shared
ThemeSegmentControl (same 4-segment control, same cookie, so a choice made
anywhere carries everywhere).

- Agent portal (agent-layout top bar): add ThemeSegmentControl (md+).
- Public client portal (InspectionHub header): add it in a wrapping right
  cluster so it stays visible at every width without colliding with the address.
- Editor: replace the bespoke cycle IconButton with ThemeSegmentControl (xl+),
  and add a Theme entry to the mobile bottom-drawer so narrow screens — which
  previously had NO theme control — can reach it. Drops the now-unused
  scheme/setColorScheme plumbing from EditorHeader + the editor route (the
  control is self-contained via useTheme).
- inspection-hub + template-edit (bare full-page routes): add it to their
  headers (xl+) so they match the editor they link into.

Report rendering is intentionally untouched (independent styling). Full web
suite green; live-verified on the editor, client portal, inspection hub, and
agent portal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSKVuFcvPArEfoDj76uQ7A
Addresses the review of the timezone commits.

- Fix a test that CI would have failed: getProfile now returns `timezone`, but
  tests/unit/agent/profile-get asserted the old shape (my earlier targeted runs
  hit tests/unit/agents/ — plural — and missed this singular dir). Add real
  coverage for the service validation branch: valid IANA persists, empty string
  clears to null, non-resolvable zone fails closed (BadRequest, nothing saved).
- Guard BrowserTimezoneHint against a non-canonical browser alias (e.g.
  Asia/Calcutta) that has no <option>: only offer "Use this" for a zone in the
  canonical list, so the uncontrolled tenant pickers can't silently persist
  "inherit/clear".
- Document that inspections.date is a mixed date/datetime column, and add a
  dashboard test proving a bare YYYY-MM-DD renders as a plain date (no time or
  zone) even under an override — the tz chain intentionally has no visible
  effect there (avoids a prior-day rollover).
- Comment accuracy: the agent-dashboard resolution note said
  tenants.default_timezone (it lives on tenant_configs); the two picker comments
  claimed the native-change dispatch makes "the save bar light up" (it is always
  shown, not dirty-gated — the dispatch drives revalidation/hint, and the
  submitted value comes from the select's DOM value).

Full test:unit (3482) + test:web green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSKVuFcvPArEfoDj76uQ7A
The agent tz change added lines to listReferrals above a pre-existing,
already-baselined by-id query (283 -> 295). No new unscoped query — the line
key moved. Regenerated the baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSKVuFcvPArEfoDj76uQ7A
- Client portal overview showed inspections.date raw (2026-07-20T00:27:12.605Z)
  in the Hub header + status cards, on both the normal-client and agent paths
  (portal.service sets date: inspection.date). Thread the tenant timezone
  through the brand chain (getBrand + PublicBrandSchema + TenantBrand +
  resolveTenantBrand) and humanize overview.date once in the loader, server-side
  — so the formatted string is serialized loader data (no client re-format, no
  hydration mismatch). Anchored to the tenant tz, the convention for portal
  surfaces.
- N3: isValidTimeZone now rejects abbreviations / legacy fixed-offset zones
  ('EST', 'GMT', 'PST8PDT') — a stored zone must be an IANA region id (contains
  '/') or 'UTC', so the runtime always handles DST. Spec updated.
- M3: extract the onboarding pre-fill decision into a pure, unit-tested helper
  onboardingTzPrefill (also folds in the canonical-zone guard), and use it from
  the workspace effect. Tests: fires only on ?setup=timezone + unset tenant +
  canonical browser zone; never overrides a real choice or a non-representable
  alias.

Branch gate: test:unit 3482, test:web 1380, full lint (15 gates), type-check
all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSKVuFcvPArEfoDj76uQ7A
ReportView rendered {data.date} verbatim — the raw inspections.date
(2026-07-20T00:27:12.605Z) — in the "date · Inspector" certification line.
Format it via formatInspectionDateTime in the report's own timezone
(data.reportTimeZone, already threaded and used by the signature/verification
blocks). One render-site change, so it fixes all three report contexts (client
portal, standalone report page, and the PDF) at once. Empty date stays blank,
as before. No change to the report's visual styling.

Report web tests (99) green; type-check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSKVuFcvPArEfoDj76uQ7A
…Tier 1+2)

Follow-up to the agent-dashboard / portal-overview / report-cert fixes: a full
audit found ~16 more user-facing spots rendering a raw machine date (full ISO
"2026-07-20T00:27:12.605Z" or a bare YYYY-MM-DD). Route each through the shared
formatters.

PUBLIC surfaces — formatted server-side in the loader (SSR-safe), anchored to
the tenant timezone via the brand chain (resolveTenantBrand().defaultTimezone),
falling back to UTC where the route carries no tenant slug:
- ProgressView (portal Hub Progress + public Observe) — both feeder loaders
  format inspections.date.
- InspectionList (client portal landing list) — portal.tsx loader.
- concierge appointment-confirm date; verify page signer "signed at";
  agreement-printable "Date Signed"; portal invoice Issued/Due (also the Hub
  payment-tab loader, loadInvoiceSection).

AUTHENTICATED surfaces — formatted in-component with the viewer's
locale/timezone (useDisplayLocale/useDisplayTimeZone):
- notifications timestamp -> formatRelativeTime ("3 hours ago").
- agreements template date; admin invoices Due + inspection-picker option;
  settings schedule/holiday panels (date-overrides / time-off / holiday /
  company-closed).

Date-only (civil YYYY-MM-DD) values are formatted with timeZone: 'UTC' so the
UTC-anchored instant renders as the same calendar day in every runtime zone (no
off-by-one). Every existing empty/null guard/fallback is preserved. No visual
styling changed. Report rendering untouched beyond the already-fixed cert line.

Updated 3 settings-panel tests to assert the formatted date. Branch gate:
type-check, test:unit 3482, test:web 1380, full lint (15 gates) all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSKVuFcvPArEfoDj76uQ7A
…-less pages

observe / verify / concierge-confirm / agreement-printable carry no tenant slug
and no session, so there is no configured zone to anchor timestamps to. They
formatted server-side against the platform UTC default; now they format in the
viewer's own browser timezone, and a "Times shown in {zone}" control names the
zone in effect and lets the viewer switch (remembered via localStorage).

- ViewerTimeZoneProvider/useViewerTimeZone: SSR-safe (UTC until mount, then a
  remembered choice or the detected browser zone) so hydration matches.
- ViewerTimeZoneNotice: print:hidden control; renders nothing until the zone
  resolves client-side.
- Loaders now return raw ISO and the page formats it. No-JS / PDF renders keep
  the fixed UTC anchor, the correct fallback for a printed document.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSKVuFcvPArEfoDj76uQ7A
…n-in gate

Spec 3 opened selfRetrieveReport for the agent kind (capabilities.ts), so a
listing agent's tokenized report link now renders the agent report landing
(agent-report-actions) instead of bouncing to the client "Sign in to your
portal" gate. The role-aware security guarantee is preserved a layer down: the
portal /exchange route still refuses to mint a client __Host-portal_session
cookie for an agent kind (returns agent:true, no Set-Cookie), so the client hub
stays locked. Mirror agent-unified-link.spec's fresh-open assertions; drop the
now-stale "Sign in to your portal" / no-property-address checks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSKVuFcvPArEfoDj76uQ7A
@important-new
important-new merged commit 622c64a into InspectorHub:main Jul 20, 2026
5 checks passed
@important-new
important-new deleted the feat/people-role-profiles branch July 20, 2026 07:52
important-new added a commit that referenced this pull request Jul 22, 2026
…262)

* fix: harden agent report-access program (post-merge review of #258)

Follow-up fixes from a review of the merged people/role-profiles + role-aware
sending + agent unified-link program (#258):

- SMS consent gate now keys on the per-recipient role, so a recipientKind=all
  rule can no longer text the client without recorded consent (TCPA)
- primary-client add is an atomic insert-if-no-client (closes a TOCTOU race)
- tenant /login excludes global-agent rows (no member lockout on a shared email)
- resolveRecipients guards listPeople so a transient DB error can't silently
  drop every report delivery
- report-token sign-in is emailed to the agent's own inbox instead of returned
  to the caller (closes a report-link -> full agent-session takeover)
- automation editor warns when recipients = "everyone on the inspection"
- analytics agent-name lookup is scoped to rendered rows and chunked under the
  D1 bind-parameter cap
- removePerson is scoped to the URL inspection id
- find-my-report redeem prefers a client session when the email holds a
  client-kind grant (agents still route to the dashboard)
- misc: reactivate 409 mapping, cross-tenant template-id rejection, idempotent
  role-profile seed, fail-closed capability default

Adds regression tests for the SMS-all, login-exclusion, and dual-identity paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YiCgWAmqyzpfs3pp1kjg3

* fix(ci): regen OpenAPI snapshot + update agent-unified-link e2e for emailed link

The magic-login/request response changed from { loginUrl } to { sent: true }
(the single-use link is emailed to the agent now, not returned), which drifted
the committed OpenAPI snapshot and broke the e2e that asserted the old
return-and-navigate flow.

- regenerate server/lib/mcp/openapi-snapshot.json
- rewrite Scenario 1 + Scenario 4 to the emailed-link flow: request -> { sent }
  -> read the single-use link from the E2E email sink -> redeem -> /agent-dashboard
  (Scenario 4 waits for a code that differs from an earlier scenario's email)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YiCgWAmqyzpfs3pp1kjg3

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
important-new added a commit to important-new/OpenInspection that referenced this pull request Aug 23, 2026
…ending, agent unified link) + viewer timezones (InspectorHub#258)

* feat(people): add contact_role_profiles + inspection_people tables

* feat(people): capabilitiesForKind capability module

* feat(people): default role profiles + idempotent seeder

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(people): seed role profiles on tenant bootstrap

* feat(people): PeopleService with single-primary-client enforcement + capability predicates

* feat(people): idempotent backfill from legacy people columns

* feat(people): inspection create writes inspection_people (client + buyer + listing agent)

createInspection now mirrors the primary client, buyer's agent, and
listing agent into the inspection_people join table via
PeopleService.addPerson, alongside the legacy clientContactId /
referredByAgentId / sellingAgentId columns (kept until a later
migration retires them). The write is best-effort — a people-write
failure never rolls back an already-committed inspection row.

Refactored the existing client soft-upsert block to capture the
resolved/created contact id instead of doing a second lookup.
Bumped the file-size ratchet baseline for the grown service file.

* feat(people): booking create writes inspection_people

The legacy single-service booking path in BookingService.fulfillBooking
inserts inspections directly (not via InspectionCoreService.createInspection,
which already got the Task 7 people-write). Mirror client + buyer_agent into
inspection_people for that direct-insert path, non-fatal like Task 7.

* feat(people): concierge create writes inspection_people

ConciergeService.createBooking inserts inspections directly (not via
InspectionCoreService.createInspection, which already got the Task 7
people-write). Mirror the resolved agent-tenant-link contact into
inspection_people (buyer_agent), non-fatal like Task 7. No client contact
id is resolved anywhere in this flow, so only the agent role is written.

* feat(people): inspection-request create writes inspection_people

InspectionRequestService.create inserts inspections directly for every
sub-inspection in a request (not via InspectionCoreService.createInspection,
which already got the Task 7 people-write). Mirror the agent referral
(input.referredByAgentId) into inspection_people (buyer_agent) for each
created sub-inspection, non-fatal like Task 7. No resolved client contact id
is available in this service, so only the agent role is written.
addSubInspection() is left untouched — its input carries no agent/client
contact id at all, so there is nothing to confidently resolve there.

* fix(people): booking writes client to inspection_people for all booking inspections (multi-service)

Task 7b wrote the client to inspection_people only for the legacy
single-service directInsertInspectionId, but bookingClientContactId is
linked to ALL booking inspections (allInspectionIds) including
multi-service sub-inspections created by InspectionRequestService.create,
which writes only buyer_agent and never the client. Multi-service
booking sub-inspections therefore had no client inspection_people row.

The client write now loops allInspectionIds; buyer_agent stays scoped to
directInsertInspectionId (multi-service owns its own buyer_agent write).

* refactor(people): getPeopleCard + getRecipientList read from inspection_people

Both methods now source client/buyer-agent/listing-agent from
PeopleService.listPeople (the inspection_people join) instead of the legacy
inline clientName/clientEmail/clientPhone columns + fetchAgentsById lookups.
Return shapes are unchanged; agent .id stays the CONTACT id (p.contactId),
matching the old contract, not the inspection_people join-row id.

fetchAgentsById is removed — these two methods were its only callers.

Updated the three legacy specs (inspection-recipients, inspection-people-card,
inspection-hub) to seed inspection_people rows alongside the legacy columns,
since they now drive what getPeopleCard/getRecipientList actually read.

* refactor(people): publish.ts /complete reads primary client via PeopleService

The report-ready auto-send on inspection completion resolved its recipient
from the legacy inspection.clientEmail/.clientName columns (dropped in Task
13). Resolve the primary client once via PeopleService.getPrimaryClient
instead, sourcing the token issuance, email send, and admin-notification
metadata from the join. Behavior-preserving: same email goes out, now sourced
from inspection_people.

Also refreshes the file-size ratchet baseline (publish.ts grew slightly;
inspection-core.service.ts's prior shrink was never re-baselined).

* refactor(people): report-delivery.ts send-report-pdf reads primary client via PeopleService

The send-report-pdf resend endpoint fell back to inspection.clientEmail when
no toEmail override was supplied (dropped column, Task 13). Fall back to
PeopleService.getPrimaryClient instead, and drop the "inspection.clientEmail"
wording from the 400 error message since that column no longer exists as a
concept the caller can set directly.

* refactor(people): invoices.ts request-payment reads primary client via PeopleService

POST /api/invoices/request-payment resolved the recipient email and the
invoice's clientName snapshot from inspection.clientEmail/.clientName
(dropped columns, Task 13). Resolve the primary client once via
PeopleService.getPrimaryClient and source both fields from the join.
Behavior-preserving.

Updates the existing invoice-request-payment.spec.ts fixture to seed the
primary client through inspection_people (mirroring what the create flow now
writes) instead of the legacy inline columns, since the route no longer reads
them.

* refactor(people): message.service.ts client helpers read primary client via PeopleService

clientEmailForInspection / clientNameForInspection (consumed by messages.ts's
client-attribution and portal-notification deep-link) read the legacy
inspections.clientEmail/.clientName columns (dropped in Task 13). Both now
resolve via PeopleService.getPrimaryClient against the inspection_people join
instead. messages.ts call sites are unchanged (same signatures).

Updates the two existing MessageService test fixtures
(client-message-routes.spec.ts, message.service.spec.ts) to seed the primary
client through inspection_people instead of the legacy inline columns, since
these helpers no longer read them.

* refactor(people): event.service reads primary client via PeopleService

scheduleReminderLog/scheduleFollowupLog resolved the automation-log
recipient off inspection.clientEmail, one of the legacy denormalized
columns being dropped (Task 13). Both now resolve the recipient via
PeopleService.getPrimaryClient (inspection_people join), tenantId
already in scope. Adds coverage seeding inspection_people without the
legacy columns.

* refactor(people): agreement envelope default signer via PeopleService

AgreementService.findOrCreate's default-signer branch (no opts.signers)
resolved name/email off insp.clientName/.clientEmail, legacy denormalized
columns being dropped (Task 13). Now resolves via
PeopleService.getPrimaryClient (inspection_people join); an explicit
opts.signers[0] still takes precedence exactly as before.

Two shared agreements test fixtures (agreement-signers-setup.ts,
inspection-sign-unification-setup.ts) seeded only the legacy columns
with no inspection_people row; backfilled a matching primary-client
contact so the many specs asserting the default signer is "Jane" /
"jane@test.com" keep passing (setup-only change, no assertions
weakened).

* refactor(people): agreement-requests route reads primary client via PeopleService

POST /api/inspections/:id/agreement-requests resolved the default
recipient email/name off inspection.clientEmail/.clientName, legacy
denormalized columns being dropped (Task 13). Now resolves via
c.var.services.people.getPrimaryClient; an explicit body.email still
overrides exactly as before.

Updates two fixtures whose buildApp() didn't wire a people service and
whose "happy path" relied on the legacy columns with no
inspection_people row backing them (setup-only changes; the
"no client resolvable" regression test now removes the
inspection_people row instead of nulling the legacy column, same
assertions).

* refactor(people): concierge approveByInspector reads primary client via PeopleService

approveByInspector gated on and emailed insp.clientEmail, a legacy
denormalized column being dropped (Task 13). Now resolves via
PeopleService.getPrimaryClient (inspection_people join).

Note: ConciergeService.createBooking does not itself write a
client-role inspection_people row (only buyer_agent — see
concierge-people.spec.ts), so the existing happy-path fixture needed a
manually-seeded primary-client contact to keep passing (setup-only;
flagged as a concern in the task report since it means concierge
bookings need a client-role person written somewhere before this
approval email will fire in production).

Bumps the file-size baseline for concierge.service.ts (+3 lines from
the conversion).

* refactor(people): ensureClientContact resolves primary client via PeopleService

ensureClientContact read inspection.clientEmail/.clientName/.clientPhone
off an inspection-shaped argument to dedupe-or-create a contact — those
legacy denormalized columns are being dropped (Task 13). Changed
signature to (dbRaw, tenantId, inspectionId): it now backfills
inspections.client_contact_id from the EXISTING contact referenced by
the inspection_people primary-client join (PeopleService.
getPrimaryClient). The dedupe-by-email/create-new-contact logic is
gone — a primary-client join always already points at a real contacts
row, so there is nothing left to create. Smaller-blast-radius choice:
the one caller (server/api/sms.ts attest route) already had the
inspection id in scope, so it now passes inspectionId directly instead
of the full inspection row.

Updates the two affected fixtures: the dedicated ensureClientContact
spec is rewritten around the new signature/behavior, and
sms-api.spec.ts's "free-typed client" attestation test — which
exercised the now-retired create-from-denormalized-strings path — is
adapted to seed an inspection_people primary-client join instead
(same assertions: 200, clientContactId backfilled, consent recorded).

* fix(people): concierge booking persists client contact + inspection_people row

ConciergeService.approveByInspector (Task 9b) resolves the client via
PeopleService.getPrimaryClient (inspection_people join), but createBooking
never wrote a client contact or inspection_people row -- only the legacy
inspections.clientEmail/clientName/clientPhone columns. Every concierge
reviewer-mode approval would throw BadRequest once those legacy columns
are dropped (Task 13).

createBooking now upserts the client as a contact (ContactService.
upsertClientContact, same idempotent match booking.service/core.ts use)
and mirrors it into inspection_people as `client`, alongside the existing
buyer_agent mirror. Non-fatal, same as the existing people-write block.

Updated concierge-service.spec.ts's approveByInspector test, which had
manually seeded a client contact + inspection_people row to work around
the gap this fix closes (now redundant and would collide with the
new auto-upsert). Extended concierge-people.spec.ts to assert the client
role is written and getPrimaryClient resolves it.

* refactor(people): PeopleService.contactIdForRole resolves buyer/listing agent via inspection_people

Adds a tenant-scoped helper that resolves the single contact occupying a
given role key (buyer_agent, listing_agent, ...) on an inspection by
joining inspection_people -> contact_role_profiles, replacing the pattern
of reading inspections.referredByAgentId / .sellingAgentId directly.
Foundational helper for the agent-attribution query rewrite (Task 9c).

* refactor(people): share-agent resolves buyer's agent via inspection_people

POST /api/inspections/:id/share-agent now resolves the linked buyer's
agent through PeopleService.contactIdForRole(tenantId, id, 'buyer_agent')
instead of reading inspections.referredByAgentId directly. Same two-step
error semantics preserved (400 "No agent linked" vs "Agent has no email
on file"). Bumps the file-size baseline for the +4 net lines.

* refactor(people): agent referral queries resolve buyer's agent via inspection_people

listReferrals, accessToInspection, listRecommendationsForAgent, and
referralsByDay in server/services/agent/referral.ts now source the
buyer-agent contact id from inspection_people (role buyer_agent) instead
of inspections.referredByAgentId. contact_role_profiles is joined BEFORE
inspection_people in every site so the join stays scoped to buyer_agent
only — joining inspection_people first would fan out over every role
(client, co_client, listing_agent, ...) an inspection carries.

Updates the agent-service-listings.spec.ts fixture to also seed the
matching inspection_people rows (setup only, no assertions weakened) and
adds a fresh test proving attribution still resolves with the legacy
column NULL. Adds tests/unit/agents/agent-referral-people.spec.ts
covering accessToInspection, listRecommendationsForAgent, and
referralsByDay directly against a real DB (previously only exercised
through mocks at higher layers).

Flags one latent multiplicity risk in referralsByDay: if an inspection
ever carried more than one buyer_agent inspection_people row, it would be
double-counted there (no downstream dedup) — not reachable via current
create paths (single buyer_agent per inspection).

* refactor(people): agent my-reports + leaderboard resolve buyer's agent via inspection_people

GET /api/agent/my-reports and GET /api/agent/leaderboard in
server/api/agent.ts no longer filter/group on
inspections.referredByAgentId directly:

- my-reports resolves the set of inspection ids via a two-step query
  (inspection_people join contact_role_profiles filtered to buyer_agent)
  before selecting flat inspection rows by id, preserving the
  `db.select()` all-columns shape.
- leaderboard's groupBy aggregate now joins contact_role_profiles before
  inspection_people (scoped to buyer_agent) then leftJoin contacts,
  keeping the existing agentId-not-null post-filter as a no-op safety net.

Adds tests/unit/agents/agent-reports-leaderboard.spec.ts (no prior
coverage existed for either route) seeding inspections with the legacy
column NULL and only inspection_people populated.

* refactor(people): metrics topAgents aggregate resolves buyer's agent via inspection_people

GET /api/metrics topAgents no longer groups/joins on
inspections.referredByAgentId. Joins contact_role_profiles (scoped to
buyer_agent) before inspection_people, then leftJoin contacts — same
single-column groupBy(inspectionPeople.contactId) as before. The old
"referredByAgentId is not null" filter becomes "inspectionPeople.contactId
is not null", which is now implicit but kept explicit for clarity/parity.

Adds tests/unit/metrics/metrics-top-agents-people.spec.ts (no prior
coverage existed for this route) seeding inspections with the legacy
column NULL and only inspection_people populated.

* docs(people): update stale referral.ts predicate comments to inspection_people

Doc comments on getAgentReferralFilter, listReferrals, and
accessToInspection still described the association predicate in terms of
inspections.referredByAgentId after the Task 9c join rewrite. Updated to
describe the actual current source (inspection_people buyer_agent
contactId) — no code change. Bumps the file-size baseline for the +2 net
comment lines.

* refactor(people): sms consent + ensureClientContact reads primary client via PeopleService

Task 9c (people-role-profiles) — GET /api/admin/sms/consent and
ensureClientContact's "already linked" fast path both read
inspections.clientContactId directly. Convert both to resolve the client
contact via PeopleService.contactIdForRole('client') (inspection_people is
the source of truth), keeping the legacy column write as a best-effort
backfill for other not-yet-converted readers. Fixed two sms-api.spec.ts
fixtures that seeded clientContactId without a matching inspection_people
row.

* refactor(people): inspection getInspection/listInspections read primary client via PeopleService

Task 9c (people-role-profiles) — InspectionCoreService.getInspection's single-
inspection response and listInspections' list response both inlined
clientName/clientEmail(/clientPhone) straight off the legacy inspections
columns. getInspection now resolves PeopleService.getPrimaryClient per
inspection (single row, cheap); listInspections uses one LEFT JOIN through
inspection_people(client role)+contacts (contact_role_profiles filtered
before inspection_people, mirroring the join order in api/metrics.ts) to
stay N+1-free across the page. Hard cutover, no legacy-column fallback,
matching the pattern already used by invoices.ts/agreements.ts/publish.ts.

* refactor(people): concierge resolveToken reads primary client via PeopleService

Task 9c (people-role-profiles) — ConciergeService.resolveToken() read
insp.clientName/.clientEmail straight off the legacy inspections columns.
Converts to PeopleService.getPrimaryClient (hard cutover, no legacy-column
fallback), mirroring approveByInspector's existing use just above it. The
GET /api/concierge/confirm-view route (api/concierge.ts) needs no change —
it only reads resolveToken()'s already-converted output.

* refactor(people): ics token feed reads primary client via PeopleService

Task 9c (people-role-profiles) — the public GET /api/ics/:token
subscription feed embedded r.clientName straight off the legacy
inspections.client_name column in each VEVENT's DESCRIPTION. Converts to a
single LEFT JOIN through inspection_people(client role)+contacts
(contact_role_profiles filtered before inspection_people, same join order as
api/metrics.ts) so the up-to-90-day window stays N+1-free. No existing
tests covered this route; added a new spec.

* refactor(people): data export reads primary client via PeopleService

Task 9c (people-role-profiles) — DataService.exportInspectionsCSV projected
client_name/client_email/client_phone straight off the legacy inspections
columns. Converts to a single LEFT JOIN through inspection_people(client
role)+contacts (contact_role_profiles filtered before inspection_people,
same join order as api/metrics.ts) so the bulk CSV export stays N+1-free.
Column order/output shape unchanged. No existing tests covered this method;
added a new spec.

* fix(people): createReinspection writes client inspection_people (carry-forward)

getInspection/listInspections (Task 9c-reads) source the client ONLY from
inspection_people (no legacy-column fallback), but createReinspection copied
the legacy clientContactId/clientName/clientEmail/clientPhone columns onto
the new round without also copying the baseline's inspection_people rows --
so every re-inspection showed a null client, and would break entirely once
Task 13 drops the legacy columns.

Now copies ALL of the baseline's inspection_people rows (client, buyer_agent,
listing_agent, ...) onto the new re-inspection via PeopleService.listPeople
-> addPerson, non-fatal (a people-write failure must never roll back the
already-committed re-inspection row).

Added the CRITICAL regression test to reinspection-create.spec.ts: seeds a
published baseline with a client inspection_people row, calls
createReinspection, asserts the new inspection's inspection_people has the
client row and getInspection resolves the client (was returning null before
this fix).

Bumped scripts/file-size-baseline.json (ratchet tripped by the new block).

* fix(people): cloneInspection writes client inspection_people (carry-forward)

getInspection/listInspections (Task 9c-reads) source the client ONLY from
inspection_people, but cloneInspection spread the source's legacy
clientContactId/clientName/clientEmail/referredByAgentId/... columns onto
the clone without copying the source's inspection_people rows -- so every
clone showed a null client, and would break entirely once Task 13 drops the
legacy columns.

cloneInspection now copies ALL of the source's inspection_people rows
(client + any agents) onto the clone via PeopleService.listPeople ->
addPerson, non-fatal (a people-write failure must never roll back the
already-committed clone row).

Added the CRITICAL regression test (new clone-people.spec.ts, no prior spec
covered cloneInspection): seeds a source inspection with a client +
buyer_agent inspection_people row, clones it, asserts the clone's
inspection_people has both rows and getInspection resolves the client. Also
covers the non-fatal path (people-copy throws, clone still succeeds).

Bumped scripts/file-size-baseline.json (ratchet tripped by the new block).

* fix(people): inspection-request create/addSubInspection write client inspection_people

getInspection/listInspections (Task 9c-reads) source the client ONLY from
inspection_people, but InspectionRequestService.create() only mirrored the
agent referral (buyer_agent) -- clientName/clientEmail stayed inline
strings with no contact-upsert, so every request-created inspection showed
a null client. addSubInspection() had no people-write at all.

create() now also upserts the client as a contact (ContactService.
upsertClientContact, same idempotent match booking.service/core.ts and
ConciergeService.createBooking use) and mirrors it into inspection_people
as `client` for EVERY sub-inspection, alongside buyer_agent. input.clientName
is a required field on CreateRequestInput, so this always runs.

addSubInspection() inherits clientName/clientEmail from the parent request
(no separate client input at that call site) and mirrors the SAME client
contact into inspection_people for the new sub-inspection -- reusing the
existing contact row via the idempotent upsert, not creating a duplicate.

Both non-fatal, matching the established pattern.

Rewrote inspection-request-people.spec.ts: updated the existing create()
tests (client is now always written alongside buyer_agent), added a
contact-reuse test, and added a new addSubInspection describe block with
the CRITICAL regression test + its non-fatal-write coverage. Also added the
missing `afterEach(() => vi.restoreAllMocks())` -- its absence let a mocked
PeopleService.prototype.addPerson leak from one test into a later,
unrelated one.

Bumped scripts/file-size-baseline.json (ratchet tripped by the new block).

* fix(people): admin bulk data import writes client inspection_people

getInspection/listInspections (Task 9c-reads) source the client ONLY from
inspection_people, but POST /api/admin/import inserted imported inspections
with ins.clientName/ins.clientEmail on the legacy inline columns and never
created a client contact or inspection_people row -- so every imported
inspection with a client showed a null client, and would break entirely
once Task 13 drops the legacy columns.

For each imported inspection with a client name/email, upserts a client
contact (c.var.services.contact.upsertClientContact, same idempotent match
used elsewhere) and mirrors it into inspection_people as `client`. Also
extended InspectionImport with optional referredByAgentId/sellingAgentId
(assumed to already be contacts.id rows in this tenant, same contract as
the legacy columns) and mirrors those into buyer_agent/listing_agent when
present. Per-row non-fatal: one bad row must not abort the rest of the
import (the inspection row already committed regardless).

Added admin-data-import-people.spec.ts (no prior spec covered this route
directly): mounts the real adminDataImportRoutes with real ContactService/
PeopleService over an in-memory DB. Covers the CRITICAL regression (client
inspection_people row written), the no-client no-op case, multi-row
imports, and the per-row non-fatal path.

* feat(people): token role references a role-profile key

inspection_access_tokens.role widens from a fixed 3-value drizzle enum
to a free-form role-profile KEY, validated in
PortalAccessService.issueToken against the tenant's active
contact_role_profiles (fail closed on unknown/retired keys). Type-layer
only — SQLite already stored plain TEXT, so no migration is needed
(db:generate confirms no-op, db:check stays clean).

Existing specs that mint tokens now seed contact_role_profiles for
their fixture tenants, since issueToken validates every effective role
(including the 'client' default) against them.

* refactor(people): find-my-report + hub exchange gated by capability, not literal roles

Replace the three hard-coded role IN ('client','co_client') / role === 'agent'
gates on find-my-report discovery (integration.routes.ts /tenants/by-email),
the known-grant query (PortalService.listRecipientInspections), and the hub
exchange reject (portal.ts /exchange) with capability-driven checks derived
from each active role profile's selfRetrieveReport capability
(capabilitiesForKind / PeopleService.roleKeysWithCapability), so a tenant's
custom role keys participate correctly instead of only the literal
client/co_client strings.

The discovery route is a cross-tenant scan with no single tenantId in scope,
so it resolves role->kind via a per-grant join against contactRoleProfiles
(keyed on the grant's own tenantId) rather than a single-tenant
roleKeysWithCapability call.

Behavior-preserving: client/co_client still self-retrieve, agent still
rejected. Existing find-my-report/exchange specs now seed role profiles
(and the exchange test harness now provides a people service) since the
filter is no longer hard-coded. File-size baseline bumped for the two
files that grew past their cap (portal.ts, integration.routes.ts).

* refactor(people): automation trigger resolves via inspection_people

resolveAddress (trigger.ts) now sources the client/selling_agent/buying_agent
delivery address from inspection_people (via contact_role_profiles), not the
legacy inspections.client_email/_phone/_contact_id/selling_agent_id/
referred_by_agent_id columns (frozen cache, dropped Task 13). Join order
mirrors api/metrics.ts / data.service.ts: contact_role_profiles filtered to
(tenant, key, active) first, then inspection_people scoped to the inspection,
then contacts. selling_agent maps to role key 'listing_agent'; buying_agent
maps to 'buyer_agent'. The inspector sms branch is unchanged (users table).

Part of Task 11a (people-role-profiles).

* refactor(people): automation flush resolves client via inspection_people

FLUSH_SELECTION.inspection.clientContactId/clientName now project from
contacts.id/contacts.name via a LEFT JOIN (contact_role_profiles filtered to
the primary-client role -> inspection_people -> contacts, same join order as
api/metrics.ts / data.service.ts), added to flush()'s baseSelect(). This
replaces the legacy inspections.client_contact_id/client_name reads (frozen
cache, dropped Task 13) at the query source, so downstream consumers of the
FlushInspection shape need no changes:

- sms.ts's SMS-consent-gate read (`inspection.clientContactId`) now sees the
  contact id resolved from inspection_people instead of the legacy column.
- template-vars.ts's `client_name` interpolation (`inspection.clientName`)
  now sees the primary client's contacts.name.

FlushInspection's type (shared.ts) drops clientContactId/clientName from the
`Pick<inspections>` and adds them back as plain resolved fields — the column
COUNT is unchanged so the flush-column-budget spec still holds.

Part of Task 11a (people-role-profiles).

* test(automation): cover inspection_people sourcing, fix legacy fixtures

Adds automation-people-sourcing.spec.ts (Task 11a TDD coverage): seeds
inspections with the legacy client/agent columns NULL and only
inspection_people populated, asserting resolveAddress (email/sms across
client/selling_agent/buying_agent), the SMS consent gate, and client_name
template interpolation all resolve correctly off the new join — these fail
against the pre-Task-11a implementation.

Fixes the pre-existing automation fixtures across this suite that seeded only
the legacy inspections.client_*/selling_agent_id columns with no
inspection_people row, which broke once resolution moved to the join. Every
fixture now also seeds a contact_role_profiles + contacts + inspection_people
row for the client role (matching the Task 9c fixture pattern already used in
inspection-core-reads-primary-client.spec.ts / data-service-export-primary-
client.spec.ts). No assertions were weakened — same expected addresses/names,
same status transitions.

Part of Task 11a (people-role-profiles).

* fix(people): restore never-throw resilience in automation contactForRole (try/catch)

* refactor(people): erasure targets contacts + inspection_people, not legacy inspection columns

The GDPR erasure orchestrator's inspections.client_* NULL step was obsolete:
client PII now lives on contacts (joined via inspection_people), and the
contacts delete step already erases it. Replace the inspections update with
an inspection_people orphan-cleanup delete, ordered before the contacts
delete so the subject's contact id(s) can still be resolved. admin.service's
eraseClientData now counts matched inspections via inspection_people ->
contacts instead of the frozen inspections.client_email cache. Manifest
updated to match; tests prove the subject's PII is actually gone via the
same primary-client join production code reads (PeopleService.getPrimaryClient).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(people): inspection-list search matches client name via inspection_people

listInspections' free-text search predicate compared against
inspections.clientName, a legacy column no longer written by the
people-role-profiles path. Point it at the contacts.name alias already
projected by the primary-client LEFT JOIN chain, so a client whose name
lives only in inspection_people (no legacy column) is still found by search.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(people): agent/referral.ts reads client via inspection_people

listReferrals now sources clientName from a client-role inspection_people/
contacts LEFT JOIN (aliased alongside the existing buyer_agent join) instead
of the legacy inspections.client_name column, which survives GDPR erasure as
a stale denormalized cache and was leaking an erased subject's name. Adds an
anti-leak regression test proving a post-erasure inspection (legacy column
populated, no client inspection_people row) resolves clientName to null.

file-size-baseline.json bumped for referral.ts's small growth from the added
aliased join (478 -> 521 lines).

* refactor(people): inspection-request.service.ts reads client via inspection_people

.list() and .get() now source each sub-inspection's clientName from a
client-role inspection_people/contacts LEFT JOIN (role filter joined first,
mirroring api/metrics.ts / InspectionCoreService.listInspections) instead of
the legacy inspections.client_name column, which survives GDPR erasure as a
stale denormalized cache and was leaking an erased subject's name. The parent
inspectionRequests table's own clientName/clientEmail/clientPhone columns are
untouched (different table, not part of this cutover).

Adds an anti-leak regression test proving that after simulating erasure
(deleting the client's inspection_people + contacts rows, leaving the legacy
column stale) list()/get() resolve clientName to null, not the stale name.
Also seeds role profiles in this spec file's fixture so create() actually
writes the inspection_people row the new reads depend on.

file-size-baseline.json bumped for this file's small growth (469 -> 507).

* refactor(people): email/transactional.ts reads client via inspection_people

sendMessageNotification now resolves the primary client through
PeopleService.getPrimaryClient (inspection_people), not the legacy
inspections.client_email/client_name columns, for both the client-recipient
email address AND the inspector-recipient "from <name>" fallback. The legacy
columns survive GDPR erasure as a stale denormalized cache and were leaking
the erased subject's email + name through this send path.

Adds a new spec (tests/unit/email/transactional-message-notification.spec.ts)
covering correct sourcing plus two anti-leak regressions: post-erasure (no
inspection_people client row, legacy columns still populated) sends no email
to the stale address, and the inspector-recipient fromName falls back to a
generic label instead of the erased subject's stale name.

* refactor(people): inspection-publish.service.ts hub reads client/agent via inspection_people

getInspectionHub's flat inspection.clientName/clientEmail/clientPhone/
clientContactId/referredByAgentId/sellingAgentId fields now resolve via
PeopleService.getPrimaryClient + contactIdForRole('buyer_agent' /
'listing_agent'), not the legacy inspections columns of the same name. Those
columns survive GDPR erasure as a stale denormalized cache; this projection
feeds the /inspections/:id hub page's bare-text client fallback and the
/contacts/:id link (app/routes/inspection-hub.tsx), so the response shape
(InspectionHubSchema field names/types) is unchanged — only the read source
moved. getPeopleCard (the `people` block) already read this way; this closes
the last of the six legacy-column reads on this endpoint.

Test fixture now seeds deliberately-diverging legacy column values (decoy
contacts for the FK-bound sellingAgentId) alongside the real inspection_people
rows and asserts the hub resolves the inspection_people values, not the
stale/decoy legacy ones — proving the read source, not just presence.

file-size-baseline.json bumped for this file's small growth (522 -> 536).

* refactor(people): contact.service.ts links contact via inspection_people

listContacts.inspectionCount and getContactDetail's history/count now
source "which inspections is this contact on" uniformly from an
inspection_people join (any role), replacing the legacy dual-path
(referredByAgentId/sellingAgentId for agents, clientContactId/clientEmail
for clients). Fixtures updated to seed inspection_people instead of the
legacy inspections columns.

* refactor(people): concierge.service.ts confirmByClient notifies agent via inspection_people

confirmByClient's agent-notify step now resolves the buyer's-agent contact
via PeopleService.contactIdForRole(tenantId, inspectionId, 'buyer_agent')
(inspection_people join), replacing the legacy inspections.referredByAgentId
column read. Non-fatal try/catch behavior preserved. Fixed the pre-existing
concierge-service.spec.ts fixture to seed role profiles (createBooking's
inspection_people mirror-write needs them to populate the buyer_agent row).

* refactor(people): data.service.ts CSV export resolves buyer_agent via inspection_people

Task 9c-X3 (FINAL reads) — exportInspectionsCSV's referred_by_agent_id column
now sources the buyer's-agent contact id from a LEFT JOIN through
inspection_people/contact_role_profiles (role=buyer_agent, tenant-scoped),
aliased to coexist with the existing primary-client join. Replaces the
legacy inspections.referredByAgentId column read (frozen cache, dropped
Task 13).

* refactor(people): inspection-analytics.service.ts dashboard resolves agent via inspection_people

Task 9c-X3 (FINAL reads) — getDashboardBuckets' agentName attribution now
sources listing_agent (selling agent) / buyer_agent (referred agent) from a
single batch inspection_people query, replacing the legacy
inspections.sellingAgentId/.referredByAgentId column reads (frozen cache,
dropped Task 13). Priority order (listing_agent over buyer_agent) preserved
from the legacy sellingAgentId-first fallback.

This was the last live read of both columns — the broad grep-guard below
confirms zero remain, clearing Task 13 (column drop) to proceed.

Includes a file-size baseline bump (628 -> 651 lines) for the incremental
join logic.

* feat(people)!: drop legacy inspection people columns (superseded by inspection_people)

Removes the six denormalized people-columns from inspections (client_contact_id,
client_name, client_email, client_phone, referred_by_agent_id, selling_agent_id)
plus their two now-dead indexes. All create/reinspect/clone/import/booking/
concierge/request write paths are rewired to read the input DTO instead and
persist WHO exclusively through inspection_people (reads were already
converted in earlier Task 9c commits on this branch).

drizzle-kit chose a full table rebuild (not a plain DROP COLUMN) for
migrations/0014_large_marvel_apes.sql; applied and verified locally only
(db:migrate + db:check clean, 87/87 tables). Remote application is a separate,
user-gated op.

Retires backfillInspectionPeople (server/services/seed/backfill-people.ts) as
a permanent no-op now that its source columns are gone — kept for the deploy
runbook step that must run against remote before this migration reaches it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(people): role-profile validation schemas

* feat(people): /api/role-profiles CRUD (system profiles protected)

Adds listProfiles/createProfile/updateProfile/deactivateProfile to
PeopleService and mounts a new admin-gated (owner/manager) OpenAPIHono
router at /api/role-profiles: GET list, POST create (slugified unique
key), PUT update, DELETE soft-deactivate. System profiles (isSystem)
reject deactivation with 409 via the existing Errors.Conflict ->
onError mapping.

* feat(people): /api/inspections/:id/people add/list/remove

* feat(people): admin Roles tab in /contacts

Adds an admin-gated Roles tab to /contacts for role-profile CRUD,
consuming the Task-2 /api/role-profiles API via a BFF loader/action
(no client fetch). RolesTable lists profiles with kind/status pills,
flags isSystem rows with a "System" pill and hides their delete
action (system profiles can never be deactivated/deleted per the
server's 409 guard). RoleProfileModal handles create/edit, locking
the kind Select whenever editing (kind is create-only server-side)
and offering optional email/SMS template links sourced from the
tenant's own message templates.

Wires a new roleProfiles typed hono-client module (Task 2's API route
existed but was never added to api-client.server.ts / api-types) and
extends the contacts loader to also fetch email+sms message templates
for the modal's template selects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(people): editable People section on inspection detail

Replaces the read-only People card on /inspections/:id with PeopleEditor,
sourced from Task 3's inspection_people list (GET/POST/DELETE
/api/inspections/:id/people) and grouped by role kind (Client / Agents /
Other). The primary client is a fixed seat (Primary pill, no remove). Add
person opens AddPersonModal: search/select an existing contact via a
debounced typeahead or create one inline, then pick a role profile from
Task 2's /api/role-profiles, submitting through a dedicated fetcher
(person-add/person-remove never share a fetcher with each other or with
any other hub mutation — RR shared-fetcher-abort).

The three new action intents (person-add/person-remove/search-contacts)
live in inspection-hub-actions.ts alongside toActionResult, keeping the
route's file-size ratchet in check.

Also closes the Plan 1A Task 13 app-side dangling reads: inspection-hub.tsx
no longer reads the physically-dropped inspections.client_name/_email/
_phone/_contact_id/referred_by_agent_id/selling_agent_id columns — the
People card and the two email defaults (send-agreement, request-payment)
now source from the inspection_people-backed people/getPeopleCard data.

* fix(people): allow inspector role to list role profiles (read-only)

GET /api/role-profiles was owner/manager-only, but the inspection People
editor lets inspectors add people (POST /api/inspections/:id/people permits
inspector). Inspectors need to LIST role profiles to pick one. Relax only the
GET route to include inspector; POST/PUT/DELETE stay owner/manager-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(people): guard wizard client + buyer-agent create writes inspection_people

Plan 1B Task 7 - verify the NewInspectionWizard submits clientName,
clientEmail, clientPhone, and buyer-agent in the payload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(people): E2E role-profile CRUD + people add (recipient picker deferred to Spec 2)

Adds tests/e2e/people-role-profiles.spec.ts covering Plan 1B Task 8 steps
1-4: admin-only Roles tab visibility, custom kind=other role profile
creation, People-section add-person via contact search grouping under
its role kind, and the single-primary-client 409 conflict surfaced in
the Add Person modal. Step 5 (automations recipient dropdown listing
role profiles) stays out of scope — it depends on Task 6's recipient
picker, deferred to Spec 2 since recipient_kind/recipient_role_profile_id
don't exist on this branch.

Fixes two real bugs the E2E surfaced:
- RoleProfileModal never auto-closed after a successful create/update:
  its onSubmit handler checked the fetcher's PREVIOUS (stale) result
  instead of the submission that just completed. Replaced with a
  useEffect keyed on fetcher.state/data, mirroring AddPersonModal's
  existing auto-close pattern.
- report-delivery.ts registered its own GET /{id}/people (Round-2 F3
  people-card route) at the exact same path as Plan 1B Task 3's new
  inspection_people-backed listPeopleRoute. Mounted earlier in the
  router chain, it silently shadowed the new endpoint for every real
  request, so PeopleEditor always received the old {inspector, client,
  buyerAgents, listingAgents} shape instead of an array and crashed
  (people.filter is not a function). Removed the dead route — its data
  is already folded into GET /hub, which is what the frontend actually
  reads. Unit tests never caught this because they mount peopleRoutes
  in isolation rather than the full aggregator; regenerated
  openapi-snapshot.json confirms no drift (last-registered route
  metadata already matched).

Also adds a stable data-testid to PeopleEditor's per-kind group heading
(plain "Other"/"Client" text collides with unrelated <option> elements
elsewhere on the page).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(people): inline-create contact id + standalone automation seed column

Two bugs surfaced by the Task 8 E2E:
1. AddPersonModal inline 'create new contact' path read data.id from the
   POST /api/contacts response, but that endpoint returns { data: { contact } };
   read data.contact.id so the created contact links correctly.
2. seedDefaultAutomations raw INSERT named column 'active' but the automations
   table column is 'is_active' (drizzle field 'active'), so every default
   automation silently failed to seed for standalone tenants. Fixed the column
   name to is_active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(people): drop orphaned InspectionPeopleResponseSchema (knip)

The GET /{id}/people route in report-delivery.ts was removed (it shadowed
Task 3's people endpoint); its response schema is now an unused export that the
knip dead-code gate flags. InspectionPeopleSchema itself stays — the /hub
aggregate schema still consumes it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sending): recipient discriminator (kind + role profile) end-to-end

Replace automations.recipient (fixed 'client'|'buying_agent'|'selling_agent'|
'inspector'|'all' enum) with recipientKind ('role'|'inspector'|'all') +
recipientRoleProfileId, so a rule can target any contact_role_profiles row
instead of a hardcoded handful. Behavior-preserving: resolveAddress/consent
gate/seed data all map to the exact same delivery targets as before; widening
to all receivesReport roles is a later task.

- Schema: automations gets recipient_kind/recipient_role_profile_id; hand-edit
  the drizzle-kit table-rebuild migration's data-copy to map the old enum
  value to the new discriminator (CASE on recipient, correlated subquery into
  contact_role_profiles by key).
- Zod: CreateAutomationSchema superRefine enforces recipientRoleProfileId
  present iff kind='role'; UpdateAutomationSchema stays partial (no refine).
- Seed writers (automation-seeds.ts / core.ts ensureSeeds / standalone.ts
  seedDefaultAutomations) resolve recipientRoleKey -> contact_role_profiles.id
  per tenant; ensureSeeds skips (not inserts null-profile) a row whose role
  isn't seeded yet. standalone.ts now seeds role profiles immediately before
  seeding automations, since handleTenantUpdate runs before
  seedStarterContent -> seedRoleProfiles in the /setup flow.
- trigger.ts resolveAddress takes (recipientKind, recipientRoleProfileId,
  channel, insp, db); sms.ts's TCPA consent gate resolves the profile's key
  to decide if it's the primary-client rule.
- Settings automations editor: recipientKind select + conditional
  recipientRoleProfileId select (role profiles fetched via the BFF loader,
  no client fetch); rule list shows a friendly recipient label. The editor
  modal is split into app/components/settings/AutomationEditorModal.tsx
  (file-size gate).
- Added PeopleService.profileIdForKey(tenantId, key) helper.

Tests: updated all automations/usage/calendar/messaging specs that seeded a
`recipient` enum value to the discriminator shape; added new resolveAddress
coverage for 'all' and an unknown recipientRoleProfileId, plus a schema
refine-boundary test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sending): sms consent-gate fails closed on role-lookup error

When the role profile lookup throws a DB error during consent-gate resolution,
the catch block now fails closed: logs the error via structured logger and
skips the send with 'consent-gate role lookup failed' reason. Previously,
a DB error would leave roleRow null, causing the PRIMARY_CLIENT_KEY check to
fail silently and allow SMS to be sent without consent verification (TCPA
fail-open regression). Now guarantees: if we cannot prove the recipient is
NOT a consent-requiring client, we do not send.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sending): role-driven resolveRecipients (all receivesReport roles)

Add a new resolveRecipients(rule, inspection, channel) resolver on the
automation trigger mixin: returns EVERY matching recipient for a rule
(recipientKind 'role' | 'all' | 'inspector'), unlike resolveAddress which
only ever targets a single address. 'all' resolves to every person on the
inspection's people list with receivesReport (currently every kind).
'inspector' has no inspection_people row, so it's resolved the same way
resolveAddress's inspector branch does (users table, lead-falls-back-to-
assigned), not via PeopleService.

Pure additive resolver: never throws (an addr-less person is logged and
skipped), never writes automation_logs. resolveAddress and its callers
(the trigger flush loop, reminders.ts) are untouched — production
delivery is unchanged until a later task wires this resolver into the
send loop with per-recipient tokens.

* chore(sending): refresh tenant-scope baseline (core.ts read-back line shift)

The automations create()/update() post-insert/post-update read-backs
(select by freshly-generated/verified id) shifted line numbers when the
recipient discriminator edits changed those method bodies. Both are
provably safe (post-insert read-back of a self-generated id; post-update
read-back of a row whose tenant ownership was already verified and whose
UPDATE is tenant-scoped). Refreeze the line-keyed baseline. Count
unchanged at 91 (no net-new violations).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sending): fan out one automation_log per recipient (+ recipient_role_key)

Rewires AutomationService.trigger()'s enqueue loop to fan out through
resolveRecipients (added by a prior task but not yet called) instead of
the single-address resolveAddress, so a rule with N matching recipients
enqueues N automation_logs rows, each stamped with that recipient's role
key on the new nullable automation_logs.recipient_role_key column. A
later task uses this to mint a role-keyed portal token per recipient at
send time; delivery (delivery.ts flush) is unchanged.

While rewiring, found and fixed a latent gap: resolveRecipients returned
raw contacts.phone with no E.164 normalization (unlike resolveAddress),
which would have sent malformed phone numbers to the SMS provider now
that resolveRecipients feeds automation_logs.recipient directly. Both
its people-list and inspector branches now normalize via normalizeE164,
matching resolveAddress's existing behavior.

Adds migrations/0016 (hand-authored ALTER TABLE, db:generate ran clean
this time) and tests/unit/automations/automation-fanout.spec.ts covering
single-recipient, multi-recipient ('all'), the email-channel widening
for non-client roles, and the inspector recipient.

* feat(sending): flush delivers report.published as per-recipient PDF + role-keyed link

Teaches the cron-driven AutomationService.flush() to deliver report.published
EMAIL logs as a per-recipient tokenized portal link + the report PDF
(rendered once per inspection, reused across every recipient in the batch),
mirroring the inline single-client send in publish.ts's completeInspection.

The new server/services/automation/report-email.ts:deliverReportEmail runs
behind an optional trailing flush() param (reportDelivery) so every existing
flush() caller/test stays on the current generic template path unchanged.
scheduled.ts wires the real deps (PortalAccessService, ReportPdfService,
InspectionService.getReportContentHash) guarded on JWT_SECRET; when absent,
report.published emails fall back to the template path with no crash.

SMS report links and all other automation triggers are untouched (deliverSms,
resolveAddress, reminders.ts, trigger() are out of scope for this task).

* fix(sending): mark report-email log skipped when nothing sent (disabled template)

sendReportReady/sendInspectionReportPdf silently returned void when the
tenant disabled the report-ready template (or sendEmail reported
not-delivered), so deliverReportEmail always marked the automation_logs
row status:'sent' even though nothing went out. Both methods now return
whether an email actually dispatched; deliverReportEmail marks the row
'skipped' (mirroring delivery.ts's __email_not_configured__ convention)
instead of a false 'sent' when delivered is false, and still 'failed'
on thrown exceptions.

* chore(sending): refresh tenant-scope baseline (delivery line-shift + report-email log updates)

Task 2b added the report-PDF branch + pdfMemo above the flush loop, shifting
the delivery.ts automation_logs status-update line numbers (all pre-existing,
already baselined). New report-email.ts status updates use the identical
pattern: update automation_logs by its PK, obtained from flush()'s global
pending-scan (a cross-tenant cron that legitimately processes each log by id).
Refreeze the line-keyed baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sending): recipient-keyed automation-log idempotency for report.published

trigger() enqueues one automation_logs row per recipient; a retry or
double-publish of report.published previously produced duplicate rows and
duplicate sends (eventId was always NULL, and uq_automation_logs_event's
partial index doesn't constrain NULL keys). Stamp a deterministic dedup
eventId (auto:report.published:<inspectionId>) for report.published only,
widen the unique index to (automation, inspection, event, channel,
recipient) so distinct recipients/channels still get their own row, and
swallow conflicts with onConflictDoNothing() on insert. flush()'s
inspection-event-var lookup now also skips the auto: prefix (same posture
as the existing reminder: synthetic key) so report delivery is unaffected.

Other triggers (e.g. agreement.viewed) keep eventId NULL and are
intentionally not deduped, since they can legitimately recur.

* feat(sending): seed default report.published rules for buyer & listing agents

Adds role-aware default report.published rules to both seed paths
(server/data/automation-seeds.ts + server/lib/integration/standalone.ts's
eager seedDefaultAutomations): Buyer's Agent seeded ACTIVE, Listing Agent
seeded INACTIVE (opt-in). The standalone.ts buyer_agent row already
existed; this adds the matching listing_agent row and closes the gap on
the ensureSeeds path. Also corrects a stale comment in automation-seeds.ts
that claimed ensureSeeds doesn't honor defaultActive (it does).

Adds a guard test (tests/unit/people/seed-automations.spec.ts) asserting
a freshly-seeded tenant always has an active client report.published
rule (the safety guard), an active buyer_agent rule, and an inactive
listing_agent rule.

* refactor(sending): /complete delivers report via automation engine (drop inline send)

POST /{id}/complete no longer issues a portal token, renders/streams the
report PDF, and sends inline (sendInspectionReportPdf / sendReportReady).
It now awaits automation.trigger({ triggerEvent: 'report.published' }),
matching how the live /publish route already delivers reports — a single
engine path (per-recipient PDF render-once + role-keyed link, cron flush).
The trigger is idempotent per inspection (auto:report.published:<id> dedup),
so a later /publish firing the same event never double-sends. Wrapped in
try/catch so a failed enqueue logs but does not 500 the completion.

Pruned now-unused imports (buildPortalUrl, getBaseUrl, resolveSignatureInspector)
that were only referenced by the removed inline-send block; resolveTenantSlug,
buildRenderReportUrl, and getBookingHost stay imported (still used by /publish).

Rewrote tests/unit/inspections/complete-route-primary-client.spec.ts: asserts
the inline send methods are no longer called and that automation.trigger
fires with the right tenant/inspection/event, plus new cases for trigger
failure (non-fatal) and the already-completed short-circuit.

* feat(sending): parameterized multi-recipient send-report endpoint

Generalizes POST /{id}/send-report-pdf from a single hardcoded
role:'client' recipient to an arbitrary set of role-keyed recipients
(contactId or one-off email + roleKey each). The report PDF renders
ONCE per request and is reused across all recipients; per-recipient
failures (no resolvable email, unknown roleKey, send failure) are
collected in the response instead of aborting the batch. No frontend
calls this endpoint yet (a later task adds the "Send report" modal),
so the request/response contract changes freely.

New Zod schema in server/lib/validations/send-report.schema.ts.
Replaces the retired single-recipient "toEmail falls back to primary
client" test with tests/unit/inspections/send-report-multi.spec.ts,
which also covers render-once, per-recipient tokens/audits, and
graceful skip semantics. Bumps the file-size baseline for
report-delivery.ts (664 -> 667 lines).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sending): Send report modal (by role / by person)

Adds a "Send report" modal to the inspection hub so an owner/manager/
inspector can pick people already on the inspection (grouped by role,
mirroring PeopleEditor) and/or a one-off email + role, then send each
recipient their own role-keyed tokenized report link via the new
POST /{id}/send-report-pdf endpoint.

- app/components/inspection/SendReportModal.tsx: shared-ui Modal/Checkbox/
  Input/Select, hidden recipients/channels JSON fields, auto-close-on-success
  via a fetcher-state effect (RoleProfileModal lesson).
- app/routes/inspection-hub.tsx: "Send report" button (published + can-publish
  gate) with its own dedicated fetcher; "send-report" action intent posting
  to the typed hono client (no cast needed).
- messages/en/inspections.json: new inspections_hub_send_report_* / report_send
  / error_send_report keys (English-only extraction phase, es-419 optional).
- scripts/file-size-baseline.json: reviewed bump for inspection-hub.tsx.

* test(sending): E2E role-aware delivery + full-suite gate

Add tests/e2e/role-aware-sending.spec.ts (final task of the role-aware
sending plan): proves the manual Send-report modal delivers a role-keyed
tokenized link to a non-client listing agent, a one-off email+role
recipient, and the AUTO report.published cron path (buyer agent) via
wrangler's real scheduled() handler.

Whole-plan gate fixes:
- automation-editor-logic-only.test.ts now asserts editor content against
  AutomationEditorModal.tsx (the editor form was extracted there in Task 0).
- Regenerate MCP openapi snapshot for the real send-report-pdf schema (Task 6).
- Refresh tenant-scope baseline for delivery.ts line shifts (Tasks 2b/3);
  the flagged by-id queries operate on already tenant-scoped flush-loop rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(agent): open selfRetrieveReport capability for agents

* feat(agent): single-use magic-login mints agent JWT (core-native, bypasses portal)

Add the agent magic-login primitive: a durable agent report token is
exchanged (POST /api/agent/magic-login/request) for a single-use KV code
(agent_ml:<code>, TTL 15m) only when a live non-revoked agent-kind token
resolves to an email with a global agent account; GET /agent/magic-login
redeems the code once (delete-before-mint) into an agent JWT cookie and
302s to /agent-dashboard. Anti-oracle: no account -> loginUrl:null (200),
bad token -> 401. Adds AgentService.accountExistsForEmail + agent/account
helper. Registers both entry points in the workers/app.ts prefix list and
the server/index.ts public allowlist (core has no isApiRoute()).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(agent): report landing branches registered (magic-login) vs unregistered (signup CTA)

When an agent opens their durable report link, the report still renders and
a CTA shows below it: a read-only POST /api/agent/report-context probe (public,
resolves the token to {kind, recipientEmail, hasAccount} — bad token -> kind:null,
report renders regardless) drives AgentReportActions — 'Go to my workspace'
(posts the agent-magic-login intent to the portal-inspection route action ->
/api/agent/magic-login/request -> full-nav to the single-use loginUrl) when an
agent account exists, else 'Create your free agent account' linking
/agent-signup?email=&returnTo=. BFF throughout (no client fetch); DS tokens +
dark mode; recipientEmail exposure is deliberate (token holder is the recipient).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(agent): signup prefill + returnTo for report-link conversion

* fix(agent): full-suite gate for Spec 3 agent routes (isolation guard + MCP snapshot)

- Reword magic-login.service.ts comment to drop the literal
  server/portal/integration.routes.ts path string, which the SaaS-portal
  isolation guard (tests/unit/sync/portal-isolation.spec.ts, a naive
  content grep) flagged as a raw cross-boundary import.
- Regenerate the MCP OpenAPI snapshot for the new agent routes
  (/api/agent/magic-login/request, /agent/magic-login, /api/agent/report-context).

Both only surface in the full test:unit suite, not the per-task targeted
filters — caught by a full-suite sweep after Tasks 1-4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(agent): profile round-trip (GET /api/agent/profile + wire slug/notification save)

Add GET /api/agent/profile (same c.get('user').sub identity as POST /profile;
agents are global tenant-null users) backed by a getProfile reader +
AgentService.getProfile. Wire the /agent-settings/profile page: loader loads the
real slug + notification prefs (was a hardcoded stub); the Save-slug button and
notification toggles now persist via the route action -> POST /profile, with a
slug-conflict 409 surfaced inline. Extract AgentProfileResponseSchema +
AgentMyRecommendationsResponseSchema + RecommendationRowSchema into
agent.schema.ts to keep agent.ts under the file-size ratchet; regenerate the
MCP snapshot for the new GET route.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(agent): dashboard highlights the just-converted inspection

* feat(agent): /agent-login dual-mode (email+password primary, magic-link fallback)

* feat(agent): tenant-null SSO handoff branch mints agent JWT

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSKVuFcvPArEfoDj76uQ7A

* fix(agent): full-suite gate for T5/T5b (route-metadata summary + isolation guard)

- /api/agent/login summary 'Agent password login' (3 words) failed the
  route-metadata 4-12-word rule -> 'Authenticate an agent by email and password'.
- sso-handoff.schema.ts comment dropped the literal
  server/portal/integration.routes.ts path string flagged by the SaaS-portal
  isolation guard.
- Regenerate MCP snapshot for the changed summary.

Both only surface in the full test:unit suite, not the per-task filters.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(agent): hub exchange skips client session for agents, preserves report view

* feat(agent): find-my-report redeem lands agents on /agent-dashboard

Spec 3 Task 7 — GET /api/portal/:tenant/redeem now branches on
findGlobalAgentByEmail(c.env.DB, email): a global agent account mints an
agent JWT (__Host-inspector_token, no tenantId, mirroring
server/api/agent/login.ts's mint exactly) and returns { email, agent: true },
NEVER the client __Host-portal_session cookie. The client/co_client redeem
path is byte-for-byte unchanged. The RR loader (app/routes/public/portal-auth.tsx)
now redirects agent redemptions to /agent-dashboard instead of the client hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSKVuFcvPArEfoDj76uQ7A

* chore(agent): refresh tenant-scope baseline for T5 auth.service line shift

Task 5 added the DUMMY_HASH export + agent-login handler above the invite
flow in auth.service.ts, shifting three already-baselined tenantInvites-by-token
queries (invite acceptance — the token IS the credential, no tenantId exists
pre-acceptance) from lines 128/184/213 to 133/189/218. Same safe queries,
new line numbers. lint:tenant-scope lives only in the full lint, not test:unit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(core): portal request-link recovery guidance

Add a match-agnostic guidance line to the client portal's request-link
success state so a client who mistyped their email (or used a different
one than their inspector has on file) isn't left waiting silently for an
email that never arrives. Renders inside the existing single success
state — no new "not found" branch — so anti-enumeration is preserved:
the copy never reveals whether a match was found.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSKVuFcvPArEfoDj76uQ7A

* test(agent): E2E agent-unified-link (core flows) + full-suite gate

Add tests/e2e/agent-unified-link.spec.ts (Spec 3 Task 8), modeled directly
on role-aware-sending.spec.ts's harness/helpers, covering scenarios 1-4 of
the agent-unified-link plan: registered-agent report-link conversion to an
authenticated /agent-dashboard session, unregistered-agent signup
conversion with the welcome highlight, /agent-login password + magic-link,
and the security guarantee that a report token alone never authenticates
/agent-dashboard (plus single-use code enforcement). Scenario 5 (SaaS
Google-OAuth) is skipped with a TODO per the task brief.

Writing the E2E surfaced two real gaps in the already-merged Spec 3 work
that this commit also fixes, since they blocked MUST-PASS scenarios:
  - agent/signup.tsx's action never forwarded the agent-signup API's
    Set-Cookie into the browser session, so a converting agent bounced
    back to /login instead of landing on /agent-dashboard authenticated.
    Fixed to mirror agent/login.tsx's existing pattern, and reordered the
    action's redirect-target precedence so a report-path returnTo's
    welcome-highlight redirect isn't permanently shadowed by the API's
    static `/agent-dashboard` redirect.
  - the agent-login-link email trigger was never registered in the email
    template registry, so EmailService.sendAgentLoginLink always threw
    "Unknown email template trigger" and the magic-link email never sent.
    Added the missing registry descriptor (updates two hardcoded registry
    count assertions + the file-size baseline accordingly).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSKVuFcvPArEfoDj76uQ7A

* chore(agent): remove dead exports flagged by knip (branch-gate)

Spec 3 introduced 8 unused export surfaces the full-lint dead-code gate flags:
- agent/login.ts: drop the redundant `export default` (the named
  agentLoginRoutes is what server/index.ts mounts).
- agent.schema.ts RecommendationRowSchema, send-report.schema.ts
  SendReportSkippedSchema, portal-exchange.ts EMPTY_STATUS_OVERVIEW: un-export
…
important-new added a commit to important-new/OpenInspection that referenced this pull request Aug 23, 2026
…orHub#258) (InspectorHub#262)

* fix: harden agent report-access program (post-merge review of InspectorHub#258)

Follow-up fixes from a review of the merged people/role-profiles + role-aware
sending + agent unified-link program (InspectorHub#258):

- SMS consent gate now keys on the per-recipient role, so a recipientKind=all
  rule can no longer text the client without recorded consent (TCPA)
- primary-client add is an atomic insert-if-no-client (closes a TOCTOU race)
- tenant /login excludes global-agent rows (no member lockout on a shared email)
- resolveRecipients guards listPeople so a transient DB error can't silently
  drop every report delivery
- report-token sign-in is emailed to the agent's own inbox instead of returned
  to the caller (closes a report-link -> full agent-session takeover)
- automation editor warns when recipients = "everyone on the inspection"
- analytics agent-name lookup is scoped to rendered rows and chunked under the
  D1 bind-parameter cap
- removePerson is scoped to the URL inspection id
- find-my-report redeem prefers a client session when the email holds a
  client-kind grant (agents still route to the dashboard)
- misc: reactivate 409 mapping, cross-tenant template-id rejection, idempotent
  role-profile seed, fail-closed capability default

Adds regression tests for the SMS-all, login-exclusion, and dual-identity paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YiCgWAmqyzpfs3pp1kjg3

* fix(ci): regen OpenAPI snapshot + update agent-unified-link e2e for emailed link

The magic-login/request response changed from { loginUrl } to { sent: true }
(the single-use link is emailed to the agent now, not returned), which drifted
the committed OpenAPI snapshot and broke the e2e that asserted the old
return-and-navigate flow.

- regenerate server/lib/mcp/openapi-snapshot.json
- rewrite Scenario 1 + Scenario 4 to the emailed-link flow: request -> { sent }
  -> read the single-use link from the E2E email sink -> redeem -> /agent-dashboard
  (Scenario 4 waits for a code that differs from an earlier scenario's email)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YiCgWAmqyzpfs3pp1kjg3

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant