Repository navigation
feat: agent report-access program (people/role profiles, role-aware sending, agent unified link) + viewer timezones - #258
Merged
important-new merged 107 commits intoJul 20, 2026
Conversation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…capability predicates
…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.
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
inspection_people+contact_role_profilestables. Inspections link to contacts through role profiles instead of the legacyclient_*/*_agent_idcolumns (now dropped)./contacts; editable People section on the inspection detail./api/role-profilesCRUD (system profiles protected) and/api/inspections/:id/peopleadd/list/remove.inspection_people(transactional email, CSV export, analytics, publish/hub, concierge, contact linking).Spec 2 — Role-aware sending
resolveRecipientsfans out to everyreceivesReportrole.automation_logper recipient (recipient_role_key) with recipient-keyed idempotency; SMS consent-gate fails closed on role-lookup error./completedelivers via the automation engine.Spec 3 — Agent unified link
/agent-logindual-mode (email+password primary, magic-link fallback)./agent-dashboard.Timezones, theme & date hygiene
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