Track K — report lifecycle: amendments (#120) + re-inspections (#119)#135
Merged
important-new merged 43 commits intoJun 9, 2026
Merged
Conversation
The DB stores dates as ISO datetimes (e.g. '2026-05-29T09:00:00'). This value was loaded verbatim into the form, causing two problems: 1. <input type='date'> could not parse it (showed empty placeholder) 2. sanitizeSettingsPatch bypassed the date→ISO conversion, so the API received a datetime without a timezone suffix, failing z.string().datetime() Fix: strip the time component on load so the date picker gets 'YYYY-MM-DD', which sanitizeSettingsPatch then correctly converts to a Z-suffixed ISO string. Applied to closingDate too for consistency.
api.team.members returns {data:{members:[...]}} — the loader was reading
body.data (an object) and calling .filter() on it, causing a TypeError crash.
Align with the BFF sheet route which correctly reads data?.members.
…ings BFF pageSize: "200" fails Zod refine (valid: 12/25/50/100); the .catch(() => null) silently swallowed the 400 so the template dropdown was always empty. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lates & dashboard - TemplateCombobox: new reusable combobox with debounced search, cursor pagination (25/page), keyboard nav, load-more; replaces <select> in InspectionSettingsSheet (also fixes pageSize 200->100 Zod refine bug) - /resources/template-search BFF: token-relay loader for TemplateCombobox - templates.tsx: upgraded from client-side useMemo to URL-based server-side search (?q=) with 350ms debounce; sort remains client-side - server: listTemplates() gains optional q param with LIKE filter; listTemplatesRoute schema extended with q; exactOptionalPropertyTypes fix - /resources/inspection-search BFF: full cross-all-inspections search via GET /api/inspections with search+cursor params - dashboard.tsx: searchQuery now triggers server-side BFF fetch (300ms debounce) instead of client-side bucket filter; load-more for results Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…0 data-integrity batch)
…eadiness + envelope secrets)
…ent tables, sms_mode, Twilio secret keys
…dress resolution + fan-out
…review)
Zod's `.partial()` over a field carrying `.default()` still applies the
default and injects the key. UpdateAutomationSchema derived `channels`
(and `delayMinutes`) from CreateAutomationBase, so every partial PATCH
omitting `channels` parsed to `channels: ['email']`. The service gates
on `'channels' in data`, so any such update silently dropped a tenant's
enabled SMS channel (data loss). `delayMinutes` had the same hazard via
`{...rest}`, resetting a configured delay to 0.
Fix: drop `.default()` from the base fields; re-add the defaults only on
CreateAutomationSchema. Update keeps both optional with no default, so
omitting them leaves the keys absent and the service never rewrites them.
Regression covered at the schema-parse level (service tests bypass Zod).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n SMS runtime wiring Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…SMS (migration 0026) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…_configs DDL The workers tests hand-maintain an inline CREATE TABLE tenant_configs; Task 1 (sms_mode) and Task 7 (company_phone, migration 0026) added columns the Drizzle apply path now SELECTs/writes, breaking test:workers (13/33). Mirror both columns at their schema positions. test:workers 33/33 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ent contact Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… inbound webhook, booking checkbox + email link Task 8 (Track L): - server/api/sms.ts: public opt-in resolve/confirm + two-shape Twilio inbound webhook (platform shared-number /sms/inbound + tenant-scoped /sms/inbound/:slug), signature-validated (APP_BASE_URL+path); admin attest/test-send/consent-status (requireRole owner/admin). Routers mounted in server/index.ts. - Opt-in token (Step 0 decision): NO new table — self-describing sealed payload <tenantId>~sealToken(contactId) via lib/sms/optin-token.ts (reuses the config-crypto tier-2 envelope, same as agreement tokens). - app/routes/public/sms-optin.tsx: BFF SSR double-opt-in page (DS tokens, dark-safe). - Path A: booking-form unchecked SMS opt-in checkbox -> granted (booking_form). - Path B: opt-in link injected into the booking-confirmation email at the renderer level (survives template overrides / rule disabling). - audit: sms.consent.attest + sms.test_send; route tag 'sms' allowlisted; TWILIO_* added to request Bindings; typed BFF client surface registered. - settings-automations.tsx: minimal channels[]/recipient interface alignment so the tree type-checks after the Task 6 rename (full multi-channel editor is Task 9). NOTE: inbound non-command bodies are acknowledged but NOT persisted (automation_logs automation_id/inspection_id are NOT NULL; an inbound reply binds neither; two-way surfacing is out of scope per spec §10). STOP/START consent sync is fully handled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… + consent UI (Task 9)
Track L Task 9 + carried-over Task 8 review fixes:
A. Parse channels on output — AutomationService.list/create/update now return
`channels` as string[] (new private serializeRow) so the typed API surface
(AutomationSchema.channels: string[]) is truthful end to end.
B. Editor multi-channel (settings-automations.tsx) — channel checkboxes
(Email/SMS) replacing the disabled select; conditional SMS body textarea with
a live char counter + ~N segments hint; email subject/body section shown when
Email is checked; >=1 channel enforced client-side (Save disabled + hint);
save reads form.getAll("channels") + smsBody (nulled when SMS off); per-rule
+ run-log channel badge; run-log renders l.recipient. SMS placeholder palette.
C. Residual recipientEmail rename — verified no automation-log read still uses
recipientEmail (only renderer is settings-automations, already on l.recipient;
inspection-hub:737 is an unrelated payment-modal prop).
D. Settings -> Communication SMS section — sms_mode toggle + companyPhone via
extended PATCH /api/admin/tenant-config (schema+handler+GET); effective-source
line from new GET /api/admin/sms/config (resolveTwilioSource, no secrets); 3
Twilio SecretFields via existing PUT /api/admin/secrets; A2P 10DLC guidance;
inbound webhook URL (own/standalone only); Send-test-SMS.
E. Inspection-view consent + attest (inspection-hub.tsx) — ClientSmsConsent shows
granted/revoked/not-recorded via GET /sms/consent; inline "I confirm" attest
posts POST /sms/attest. BFF only.
Tests: extended settings-automations.spec (channels/recipient fixtures + sms
serialization), new settings-communication-sms + inspection-hub-sms-consent web
specs, resolveTwilioSource + list()-parse unit cases. All gates green:
unit 1604, web 361, workers 33, type-check 0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fs version dimension
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e per-version key)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sion PDF archive Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…utates an archived PDF row
…es + open/closed helper)
… from baseline snapshot Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… right follow-up) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…odal
Adds the hub entry point for creating a re-inspection from a published
baseline. The Report card shows a "Create re-inspection" action (gated on
status === published) that opens a custom modal listing the baseline's
flagged items with the still-open set pre-checked; the chosen ids submit
via a RR action + useFetcher to POST /inspections/:id/reinspect, then
navigate to the new draft's editor.
Candidates are sourced server-side: a new GET /inspections/:id/reinspect-
candidates endpoint + InspectionService.getReinspectCandidates computes
[{ itemId, label, originalNotes, open }] off the latest published report-
version snapshot. open default-check rule: original baseline -> rating
bucket is defect/monitor; re-inspection baseline -> isOpenStatus over the
tenant's configured follow-up statuses. The hub loader fetches these only
when the inspection is published; the client receives precomputed open
booleans (no server-only import).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ard, round, report variant) Real-workerd integration test for the InspectorHub#119 re-inspection feature: a published baseline -> round 1 (carry 5 defects, resolve 3) -> round 2 (pre-checks the 2 still-open items) -> the public report renders only the carried items, each tracing its `original` to the ROOT original defect (not the intermediate round). Also covers the unpublished-baseline gate and the original-baseline default-open (defect + monitor) candidate selection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ort (R7) + validate inspectorId tenant Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…einspection_statuses column
…reports (not only status=published) Chrome E2E caught the entry button never rendering: it was gated on inspection.status === 'published', but a published report sets status 'delivered'. Gate on reportPublished (delivered || published) for both the button and the loader's candidate fetch, matching the report card.
…nspectorHub#134/InspectorHub#136) - inspection-hub.tsx: keep reinspectCandidates loader + destructure (Track K InspectorHub#119); remove duplicate attestSms/attesting declarations introduced by merge - _journal.json: keep migrations 0027 + 0028 (Track K) - automation-flush-sms.spec.ts: keep today-date flakiness fix from upstream/main - cmd-consumer.spec.ts + cmd-fixtures.spec.ts: keep reinspection_statuses column (Track K)
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.
Track K — Report lifecycle (#119 + #120)
The final M1 track: makes published reports immutable + verifiable, and adds first-class re-inspections.
#120 — Report amendments after publish
Every explicit publish becomes an immutable, Ed25519-signed, hash-chained
report_versionsrow, with a per-version archived PDF and a public no-login crypto verifier. Live editing is untouched; the integrity layer only engages on explicit publish.report_versionsintegrity columns (content_hash,prev_hash,signature,key_fingerprint,is_amendment,verification_token) + per-versionreport_pdfs.version_number(migration 0027)snapshotOnPublishsigns the SHA-256 content hash with the tenant's existing e-sign key and chainsprev_hashverifyByTokenrecomputes the hash, verifies the signature against the recomputed content, and validates the chain; tolerates legacy null-hash rowsmarkQueuedis version-scoped so re-publish never mutates an archived rowGET /api/public/verify/report/{token}+ amendment-trail banner on the client report page#119 — Re-inspections / follow-up inspections
A re-inspection is a new linked inspection that carries forward the still-open flagged items from any published baseline, re-rates them with a configurable neutral follow-up status, and renders a "selected items only" report variant (left = root original finding, right = follow-up).
source_inspection_id/root_inspection_id/reinspection_round+tenant_configs.reinspection_statuses(migration 0028)createReinspection(gate: baseline must be published; root-original propagation across chain levels; seeds only selected items)POST /inspections/:id/reinspect+GET /inspections/:id/reinspect-candidatesreinspectionblockTesting
status === 'published'but published reports have statusdelivered→ the button never rendered. Now gated on report-published (delivered || published).Closes #119, closes #120.
🤖 Generated with Claude Code