Skip to content

Track K — report lifecycle: amendments (#120) + re-inspections (#119)#135

Merged
important-new merged 43 commits into
InspectorHub:mainfrom
important-new:feat/track-k-report-lifecycle
Jun 9, 2026
Merged

Track K — report lifecycle: amendments (#120) + re-inspections (#119)#135
important-new merged 43 commits into
InspectorHub:mainfrom
important-new:feat/track-k-report-lifecycle

Conversation

@important-new

Copy link
Copy Markdown
Contributor

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_versions row, 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_versions integrity columns (content_hash, prev_hash, signature, key_fingerprint, is_amendment, verification_token) + per-version report_pdfs.version_number (migration 0027)
  • snapshotOnPublish signs the SHA-256 content hash with the tenant's existing e-sign key and chains prev_hash
  • verifyByToken recomputes the hash, verifies the signature against the recomputed content, and validates the chain; tolerates legacy null-hash rows
  • per-version immutable PDF archive (versioned R2 key); markQueued is version-scoped so re-publish never mutates an archived row
  • public GET /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).

  • link columns 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-candidates
  • "Create re-inspection" modal in the inspection hub; re-inspection report variant gated on the report payload's reinspection block

Testing

  • unit 1620 · web 364 · workers 41 (incl. real-workerd e2e for both features) — all green; full type-check clean
  • Chrome E2E on the built worker caught + fixed a real bug: the re-inspection entry was gated on status === 'published' but published reports have status delivered → the button never rendered. Now gated on report-published (delivered || published).

Closes #119, closes #120.

🤖 Generated with Claude Code

important-new and others added 30 commits June 6, 2026 21:51
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>
…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>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
important-new and others added 13 commits June 9, 2026 02:55
…sion PDF archive

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… 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>
…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)
@important-new important-new merged commit b2c2d0c into InspectorHub:main Jun 9, 2026
1 check passed
@important-new important-new deleted the feat/track-k-report-lifecycle branch June 9, 2026 02:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Report amendments after publish Re-inspections (follow-up inspections)

1 participant