From 6aecf985c4d1627e18eaa407337b1c21629b8f45 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 31 Jul 2026 10:22:51 +0800 Subject: [PATCH 01/48] feat(notifications): one class vocabulary, and 8 templates a tenant could wrongly disable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `renderer.ts` reads `descriptor.required` to decide whether a tenant may switch a template off for everybody. Only 2 of 20 templates carried it, so today an operator can disable: password-reset every user loses account recovery workspace-invitation an invited colleague can never join agent-invite same, for agents agent-login-link an agent is locked out with no way back agreement-request the client never gets the link to sign payment-request we do not tell someone they owe money report-ready(-pdf) the report delivery itself All eight are spec §2.0/§2.1 NEVER rows. Verified against production: zero tenants have disabled any template, so this closes the hole without changing anyone's live behavior. The deeper problem was two surfaces asking the same question in isolation — the OPERATOR's kill switch (renderer.ts) and the RECIPIENT's (the preferences screen this unblocks). They are one question: a notification that must reach someone for legal or operational reasons must not be suppressible by EITHER party. So there is one flag and both read it; keeping them separate would let a tenant disable mail the recipient is told is "always sent". server/lib/notifications/classes.ts is that vocabulary, and classes.spec.ts makes spec §2 executable rather than aspirational: - every registry trigger must have a class - registry.required must EQUAL class.required, so the two axes cannot diverge - every class must appear in exactly one of NEVER_OFF / RECIPIENT_MAY_MUTE, so a newly added notification fails the build until someone decides - isSuppressible() fails closed on an unknown id Proved the gate is real before relying on it: run against the old registry it named exactly those eight and nothing else. Two existing specs pinned the old answer and were updated, not weakened: email-registry's hardcoded ['agreement-signed','evidence-pack'] was a snapshot of a wrong answer and its coverage moved (strictly stronger) into classes.spec; email-override-render used report-ready as its "non-required" example and now uses booking-confirmation, which is a genuine operator choice. Spec: docs/superpowers/specs/2026-07-31-notification-preferences-design.md §3.1 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq --- scripts/file-size-baseline.json | 4 +- server/lib/email-templates/registry.ts | 16 ++-- server/lib/notifications/classes.ts | 93 +++++++++++++++++++ .../unit/email/email-override-render.spec.ts | 7 +- tests/unit/email/email-registry.spec.ts | 12 ++- tests/unit/notifications/classes.spec.ts | 90 ++++++++++++++++++ 6 files changed, 206 insertions(+), 16 deletions(-) create mode 100644 server/lib/notifications/classes.ts create mode 100644 tests/unit/notifications/classes.spec.ts diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 7b4297759..8da661987 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -2,7 +2,7 @@ "app/routes/inspection-edit.tsx": 2489, "app/routes/inspector-portal.tsx": 1180, "server/services/inspection/inspection-core.service.ts": 1117, - "server/services/booking.service.ts": 969, + "server/services/booking.service.ts": 968, "server/services/inspection/inspection-report.service.ts": 920, "server/durable-objects/inspection-doc.ts": 912, "server/lib/collab/results-doc.ts": 874, @@ -38,8 +38,8 @@ "server/api/inspections/media-studio.ts": 530, "server/api/portal.ts": 526, "server/services/portal-access.service.ts": 525, - "server/api/inspections/publish.ts": 519, "server/api/repair-builder.ts": 518, + "server/api/inspections/publish.ts": 516, "app/components/settings/ManagedComplianceWizard.tsx": 514, "server/api/bookings/agreement.ts": 510, "app/routes/settings-profile.tsx": 505, diff --git a/server/lib/email-templates/registry.ts b/server/lib/email-templates/registry.ts index f8433aef4..47e2a6199 100644 --- a/server/lib/email-templates/registry.ts +++ b/server/lib/email-templates/registry.ts @@ -7,7 +7,7 @@ export const REGISTRY: EmailTemplateDescriptor[] = [ name: 'Password reset', category: 'system', editable: false, - required: false, + required: true, brand: 'platform', defaultSubject: 'Reset your password', blocks: [ @@ -26,7 +26,7 @@ export const REGISTRY: EmailTemplateDescriptor[] = [ name: 'Workspace invitation', category: 'system', editable: true, - required: false, + required: true, brand: 'tenant', defaultSubject: "You've been invited to join a workspace", blocks: [ @@ -47,7 +47,7 @@ export const REGISTRY: EmailTemplateDescriptor[] = [ name: 'Partner agent invite', category: 'agent', editable: true, - required: false, + required: true, brand: 'tenant', defaultSubject: '{{inspectorName}} invited you to be a partner agent', blocks: [ @@ -75,7 +75,7 @@ export const REGISTRY: EmailTemplateDescriptor[] = [ name: 'Agent sign-in link', category: 'agent', editable: true, - required: false, + required: true, brand: 'platform', defaultSubject: 'Sign in to your agent account', blocks: [ @@ -115,7 +115,7 @@ export const REGISTRY: EmailTemplateDescriptor[] = [ name: 'Report ready', category: 'client', editable: true, - required: false, + required: true, brand: 'tenant', defaultSubject: 'Property Inspection Report: {{address}}', blocks: [ @@ -135,7 +135,7 @@ export const REGISTRY: EmailTemplateDescriptor[] = [ name: 'Report ready (PDF)', category: 'client', editable: true, - required: false, + required: true, brand: 'tenant', defaultSubject: 'Property Inspection Report: {{address}}', blocks: [ @@ -156,7 +156,7 @@ export const REGISTRY: EmailTemplateDescriptor[] = [ name: 'Agreement signing request', category: 'client', editable: true, - required: false, + required: true, brand: 'tenant', defaultSubject: 'Please sign: {{agreementName}}', blocks: [ @@ -177,7 +177,7 @@ export const REGISTRY: EmailTemplateDescriptor[] = [ name: 'Payment request', category: 'client', editable: true, - required: false, + required: true, brand: 'tenant', defaultSubject: 'Payment request: {{amount}}', blocks: [ diff --git a/server/lib/notifications/classes.ts b/server/lib/notifications/classes.ts new file mode 100644 index 000000000..262f030cd --- /dev/null +++ b/server/lib/notifications/classes.ts @@ -0,0 +1,93 @@ +/** + * The notification CLASS vocabulary — one id per kind of notification we send, + * and the single answer to "may this be switched off at all?". + * + * Two different surfaces were already asking that question and neither could + * see the other: + * + * - the OPERATOR axis — `renderer.ts` reads `descriptor.required` to decide + * whether a tenant may disable a template for everybody + * - the RECIPIENT axis — the preferences screen, which is why this file exists + * + * They are the same underlying question. A notification that must reach someone + * for legal or operational reasons must not be suppressible by EITHER party, so + * there is one flag and both read it. Keeping them separate would let a tenant + * disable the password-reset email that a recipient is told is "always sent". + * + * `required: true` means: switching this off locks the recipient out of their + * account, hides money they owe or are owed, or destroys their only copy of + * something they signed. It is not a synonym for "important". + * + * The authority for each value is the inventory in + * `docs/superpowers/specs/2026-07-31-notification-preferences-design.md` §2. + * `classes.test.ts` makes that authority executable: every class must be placed + * in one of two explicit lists, so a new notification cannot be added without + * someone deciding which it is. + */ +import type { AutomationChannel } from '../../services/automation/shared'; + +export type NotificationCategory = 'transactional' | 'operational' | 'marketing'; + +export interface NotificationClass { + /** Stable id. For registry-backed email this IS the template trigger. */ + id: string; + /** Recipient-facing name. Not the operator's shorthand. */ + label: string; + category: NotificationCategory; + /** May this be switched off at all — by the operator OR the recipient? */ + required: boolean; + channels: AutomationChannel[]; +} + +export const NOTIFICATION_CLASSES: NotificationClass[] = [ + // ─── account access (spec §2.0) — every one of these is the delivery + // mechanism for getting INTO the account, so none may be switched off. + { id: 'password-reset', label: 'Password reset', category: 'transactional', required: true, channels: ['email'] }, + { id: 'workspace-invitation', label: 'Workspace invitation', category: 'transactional', required: true, channels: ['email'] }, + { id: 'agent-invite', label: 'Partner agent invite', category: 'transactional', required: true, channels: ['email'] }, + { id: 'agent-login-link', label: 'Agent sign-in link', category: 'transactional', required: true, channels: ['email'] }, + // No registry entry yet — the route hand-builds the HTML. P3 converts it; + // the class exists now so the gate can already see it (spec §2.0 #9). + { id: 'client-portal-login', label: 'Client portal sign-in link', category: 'transactional', required: true, channels: ['email'] }, + + // ─── money and legal record (spec §2.1) + { id: 'agreement-request', label: 'Agreement to sign', category: 'transactional', required: true, channels: ['email'] }, + { id: 'agreement-signed', label: 'Your signed agreement', category: 'transactional', required: true, channels: ['email'] }, + { id: 'evidence-pack', label: 'Signature certificate', category: 'transactional', required: true, channels: ['email'] }, + { id: 'payment-request', label: 'Invoice', category: 'transactional', required: true, channels: ['email'] }, + { id: 'report-ready', label: 'Your report is ready', category: 'transactional', required: true, channels: ['email'] }, + { id: 'report-ready-pdf', label: 'Your report (PDF)', category: 'transactional', required: true, channels: ['email'] }, + + // ─── your inspection (spec §2.2) — the recipient may switch these off + { id: 'booking-confirmation', label: 'Booking confirmation', category: 'transactional', required: false, channels: ['email', 'sms'] }, + { id: 'message-notification', label: 'New message from your inspector', category: 'transactional', required: false, channels: ['email', 'in_app'] }, + { id: 'agent-share-link', label: 'Shared report link', category: 'transactional', required: false, channels: ['email'] }, + + // ─── agent notifications (spec §2.3) — already recipient-controlled today + // via notifyOnReferral / notifyOnReport / notifyOnPaid. + { id: 'agent-new-referral', label: 'A new referral is booked', category: 'transactional', required: false, channels: ['email'] }, + { id: 'agent-report-ready', label: 'A report is ready to read', category: 'transactional', required: false, channels: ['email'] }, + { id: 'agent-invoice-paid', label: 'An invoice is paid', category: 'transactional', required: false, channels: ['email'] }, + + // ─── concierge (spec §2.4) + { id: 'concierge-client-confirm', label: 'Booking confirmed', category: 'transactional', required: false, channels: ['email'] }, + { id: 'concierge-inspector-review', label: 'A booking needs your review', category: 'operational', required: false, channels: ['email'] }, + { id: 'concierge-confirmed-agent', label: 'Booking confirmed', category: 'transactional', required: false, channels: ['email'] }, + { id: 'concierge-cancelled-agent', label: 'Booking cancelled', category: 'transactional', required: false, channels: ['email'] }, +]; + +const BY_ID = new Map(NOTIFICATION_CLASSES.map((c) => [c.id, c])); + +export function notificationClass(id: string): NotificationClass | undefined { + return BY_ID.get(id); +} + +/** + * Fail-CLOSED: an unknown class is treated as required, so a notification that + * has not been classified yet can never be silently suppressed by a preference. + * The gate below makes "unknown" a build failure rather than a runtime one, but + * the runtime default must still be the safe direction. + */ +export function isSuppressible(id: string): boolean { + return BY_ID.get(id)?.required === false; +} diff --git a/tests/unit/email/email-override-render.spec.ts b/tests/unit/email/email-override-render.spec.ts index a3862d8e9..9fa98ce71 100644 --- a/tests/unit/email/email-override-render.spec.ts +++ b/tests/unit/email/email-override-render.spec.ts @@ -25,8 +25,11 @@ describe('renderer override-merge', () => { expect(out.html).toContain('View Interactive Report'); }); it('enabled:false short-circuits for a non-required trigger', () => { - const r = withOverrides([{ trigger: 'report-ready', subject: null, blocks: null, enabled: false }]); - const out = r.render('report-ready', { address: 'A', reportUrl: 'u' }); + // `report-ready` used to be the example here; it is `required` now — the + // report IS the delivery, so an operator must not be able to switch it off + // (spec §2.1 #15). Booking confirmation is a genuine operator choice. + const r = withOverrides([{ trigger: 'booking-confirmation', subject: null, blocks: null, enabled: false }]); + const out = r.render('booking-confirmation', { clientName: 'Jo', address: 'A', date: 'D', time: 'T' }); expect(out.enabled).toBe(false); }); it('ignores enabled:false for a required trigger (still enabled)', () => { diff --git a/tests/unit/email/email-registry.spec.ts b/tests/unit/email/email-registry.spec.ts index 5f346f7de..cb8bf497c 100644 --- a/tests/unit/email/email-registry.spec.ts +++ b/tests/unit/email/email-registry.spec.ts @@ -13,10 +13,14 @@ describe('email template registry', () => { const platform = REGISTRY.filter(d => !d.editable).map(d => d.trigger); expect(platform).toEqual(['password-reset']); }); - it('marks exactly the two required triggers', () => { - const req = REGISTRY.filter(d => d.required).map(d => d.trigger).sort(); - expect(req).toEqual(['agreement-signed', 'evidence-pack']); - }); + // Which triggers are `required` is no longer asserted here. A hardcoded pair + // in this file was a snapshot of the answer, and the answer was wrong: only + // 2 of 20 were marked, so a tenant could disable the password-reset email and + // lock every user out of account recovery. The authority is now the class + // vocabulary, and `tests/unit/notifications/classes.spec.ts` asserts three + // things this line could not — that every trigger HAS a class, that the + // operator's kill switch agrees with the recipient's, and that a newly added + // notification fails the build until someone decides whether it may be muted. it('every cta references an existing block key + declared variable', () => { for (const d of REGISTRY) { if (!d.cta) continue; diff --git a/tests/unit/notifications/classes.spec.ts b/tests/unit/notifications/classes.spec.ts new file mode 100644 index 000000000..3f3e07d5b --- /dev/null +++ b/tests/unit/notifications/classes.spec.ts @@ -0,0 +1,90 @@ +/** + * The §2 inventory, made executable. + * + * A spec table and a code constant that are meant to agree, but are only asked + * to agree by prose, will drift — `settings-automations.test.ts` was written + * days ago for exactly this failure, after a channel was added to the schema + * and the route's filter silently dropped it. + * + * So three couplings are asserted here rather than described: + * + * 1. every email template the code can send has a class + * 2. the OPERATOR's kill switch (`descriptor.required`, read by renderer.ts) + * and the RECIPIENT's kill switch (`class.required`) agree — otherwise a + * tenant could disable a notification the screen promises is always sent + * 3. every class is placed in one of the two lists below, so adding a + * notification without deciding whether it may be muted FAILS + * + * (3) is the one that matters over time. (1) and (2) catch today's mistakes; + * (3) catches the ones nobody has made yet. + */ +import { describe, it, expect } from 'vitest'; +import { NOTIFICATION_CLASSES, isSuppressible, notificationClass } from '../../../server/lib/notifications/classes'; +import { REGISTRY } from '../../../server/lib/email-templates/registry'; + +/** + * Spec §2.0 + §2.1 — switching any of these off locks the recipient out of + * their account, hides money, or destroys their only copy of something they + * signed. Sourced from the inventory, not from what the code happens to do. + */ +const NEVER_OFF = [ + 'password-reset', 'workspace-invitation', 'agent-invite', 'agent-login-link', + 'client-portal-login', + 'agreement-request', 'agreement-signed', 'evidence-pack', 'payment-request', + 'report-ready', 'report-ready-pdf', +]; + +/** Spec §2.2-§2.4 — the recipient's call. */ +const RECIPIENT_MAY_MUTE = [ + 'booking-confirmation', 'message-notification', 'agent-share-link', + 'agent-new-referral', 'agent-report-ready', 'agent-invoice-paid', + 'concierge-client-confirm', 'concierge-inspector-review', + 'concierge-confirmed-agent', 'concierge-cancelled-agent', +]; + +describe('notification classes', () => { + it('classifies every notification — a new one cannot arrive undecided', () => { + const decided = new Set([...NEVER_OFF, ...RECIPIENT_MAY_MUTE]); + const undecided = NOTIFICATION_CLASSES.filter((c) => !decided.has(c.id)).map((c) => c.id); + expect(undecided, 'add these to NEVER_OFF or RECIPIENT_MAY_MUTE — the decision is the point').toEqual([]); + }); + + it('never lists a class twice, in the lists or in the vocabulary', () => { + const ids = NOTIFICATION_CLASSES.map((c) => c.id); + expect(new Set(ids).size).toBe(ids.length); + const both = NEVER_OFF.filter((id) => RECIPIENT_MAY_MUTE.includes(id)); + expect(both).toEqual([]); + }); + + it('marks every account-access and money/record class as required', () => { + for (const id of NEVER_OFF) { + expect(notificationClass(id), `${id} has no class`).toBeDefined(); + expect(isSuppressible(id), `${id} must not be suppressible`).toBe(false); + } + }); + + it('lets the recipient mute everything else', () => { + for (const id of RECIPIENT_MAY_MUTE) { + expect(isSuppressible(id), `${id} should be the recipient's call`).toBe(true); + } + }); + + it('gives every email template a class — the send boundary has to name one', () => { + const missing = REGISTRY.filter((d) => !notificationClass(d.trigger)).map((d) => d.trigger); + expect(missing).toEqual([]); + }); + + it('agrees with the operator kill switch renderer.ts reads', () => { + // renderer.ts: `const enabled = d.required ? true : (override?.enabled ?? true)`. + // If these two ever disagree, a tenant can disable a notification this + // codebase tells the recipient is always sent. + const disagree = REGISTRY + .filter((d) => notificationClass(d.trigger)!.required !== d.required) + .map((d) => `${d.trigger}: registry=${d.required} class=${notificationClass(d.trigger)!.required}`); + expect(disagree).toEqual([]); + }); + + it('treats an unknown class as required — fail closed, never fail quiet', () => { + expect(isSuppressible('some.future.notification')).toBe(false); + }); +}); From 4ca4b31fac887d9c71ceefe66ead86e9e5b633bd Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 31 Jul 2026 10:37:25 +0800 Subject: [PATCH 02/48] feat(notifications): the send boundary now knows what it is sending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sendEmail(to, subject, html)` carried an address and a rendered string — enough to deliver, not enough to decide. "An email to jane@x.com" cannot be matched against "Jane muted review requests", so a recipient-preference check placed at the boundary would have had nothing to check. This adds the field that makes the check possible; enforcement lands with the preference table. The design question was not "how do we pass a class id" but "how do we make it impossible to pass the WRONG one". ~20 mixin call sites each render a trigger and then send. A `classId` argument would have been a second chance to be wrong: a site could render booking-confirmation and declare report-ready, and nothing would catch it. So the trigger rides INSIDE `RenderResult`, stamped by whatever rendered it, and `sendRendered(rendered, to, …)` reads it from there. There is no argument through which a caller can name a template it did not render. 20 of 22 mixin sites convert mechanically; the two that did not are the interesting ones: - booking-confirmation appends the SMS opt-in block to the body. It spreads the render result rather than rebuilding it — rebuilding would drop the trigger and silently turn a classified send unclassified. Pinned by a test. - transactional.ts:126 is a THIRD raw call site, and §5.0's audit does not list it. That census swept ROUTES; this one is hand-built HTML inside the email service itself, where a route sweep cannot see it. Same lesson as repair-builder, one layer deeper: a census only finds what it thinks to look at. It is the free-tier quota warning, now classified `usage-quota-warning` (required — muting it means hitting the wall with no warning, the same harm as hiding money owed). Moving it onto a template is P3. The class gate proved itself on that new class before I trusted it: adding `usage-quota-warning` turned classes.spec red, named it, and refused to pass until someone decided whether it could be muted. That is the behavior the gate exists for, observed rather than assumed. An unclassified send stays SENDABLE and un-mutable — a boundary that dropped unclassified mail would turn a missing annotation into lost notifications. Spec: docs/superpowers/specs/2026-07-31-notification-preferences-design.md §5.0 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq --- server/lib/email-templates/renderer.ts | 4 +- server/lib/email-templates/types.ts | 9 ++ server/lib/notifications/classes.ts | 10 ++ server/services/email/agent.ts | 15 ++- server/services/email/agreement.ts | 15 ++- server/services/email/base.ts | 49 +++++++++- server/services/email/concierge.ts | 8 +- server/services/email/inspection.ts | 19 ++-- server/services/email/transactional.ts | 20 ++-- .../email-class-carrying-boundary.spec.ts | 91 +++++++++++++++++++ tests/unit/notifications/classes.spec.ts | 3 + 11 files changed, 201 insertions(+), 42 deletions(-) create mode 100644 tests/unit/email/email-class-carrying-boundary.spec.ts diff --git a/server/lib/email-templates/renderer.ts b/server/lib/email-templates/renderer.ts index d21fb488a..578609bf4 100644 --- a/server/lib/email-templates/renderer.ts +++ b/server/lib/email-templates/renderer.ts @@ -23,7 +23,7 @@ export class EmailTemplateRenderer { const override = this.config.overrides?.get(trigger); const enabled = d.required ? true : (override?.enabled ?? true); - if (!enabled) return { subject: '', html: '', enabled: false }; + if (!enabled) return { trigger, subject: '', html: '', enabled: false }; const allowed = d.variables.map(v => v.name); const resolve = (s: string) => interpolate(s, data, allowed); @@ -58,7 +58,7 @@ export class EmailTemplateRenderer { ...(systemHtml !== undefined ? { systemHtml } : {}), ...(opts?.signatureHtml ? { signatureHtml: opts.signatureHtml } : {}), }); - return { subject, html, enabled: true }; + return { trigger, subject, html, enabled: true }; } private buildSystemBlocks(d: EmailTemplateDescriptor, data: Record): string | undefined { diff --git a/server/lib/email-templates/types.ts b/server/lib/email-templates/types.ts index 1ea292325..5b698e0c7 100644 --- a/server/lib/email-templates/types.ts +++ b/server/lib/email-templates/types.ts @@ -38,6 +38,15 @@ export interface TemplateBrand { } export interface RenderResult { + /** + * The template trigger that produced this result — and therefore the + * notification class the send boundary will record. + * + * It lives IN the result rather than being passed alongside it so a caller + * cannot render one template and declare another: there is only one place + * the value can come from. + */ + trigger: string; subject: string; html: string; enabled: boolean; diff --git a/server/lib/notifications/classes.ts b/server/lib/notifications/classes.ts index 262f030cd..54435007f 100644 --- a/server/lib/notifications/classes.ts +++ b/server/lib/notifications/classes.ts @@ -58,6 +58,16 @@ export const NOTIFICATION_CLASSES: NotificationClass[] = [ { id: 'report-ready', label: 'Your report is ready', category: 'transactional', required: true, channels: ['email'] }, { id: 'report-ready-pdf', label: 'Your report (PDF)', category: 'transactional', required: true, channels: ['email'] }, + // ─── the workspace can no longer do its job (spec §2.6 shape) + // Warns the owner they are at / near the free-tier inspection limit. Muting + // it means hitting the wall with no warning, which is the same harm as + // hiding money owed — so it is not the recipient's to switch off. SaaS only; + // standalone has no quota (spec §2.6b). + // Found while converting call sites, NOT by the §5.0 route census: it is a + // hand-built send INSIDE the email service, where a sweep of routes cannot + // see it. Add it to the spec's §2.4b list. + { id: 'usage-quota-warning', label: 'Free inspections running out', category: 'operational', required: true, channels: ['email'] }, + // ─── your inspection (spec §2.2) — the recipient may switch these off { id: 'booking-confirmation', label: 'Booking confirmation', category: 'transactional', required: false, channels: ['email', 'sms'] }, { id: 'message-notification', label: 'New message from your inspector', category: 'transactional', required: false, channels: ['email', 'in_app'] }, diff --git a/server/services/email/agent.ts b/server/services/email/agent.ts index 780c1bbaa..de4558023 100644 --- a/server/services/email/agent.ts +++ b/server/services/email/agent.ts @@ -48,7 +48,7 @@ export function AgentEmailMixin(Base: TBase) { html: fallbackBody, }); if (!rendered.enabled) return; - await this.sendEmail([to], rendered.subject, rendered.html); + await this.sendRendered(rendered, [to]); } /** @@ -80,10 +80,9 @@ export function AgentEmailMixin(Base: TBase) { host, ); if (!rendered.enabled) return; - await this.sendEmail( + await this.sendRendered( + rendered, [to], - rendered.subject, - rendered.html, undefined, { inspector }, ); @@ -110,7 +109,7 @@ export function AgentEmailMixin(Base: TBase) { html: fallbackBody, }); if (!rendered.enabled) return; - await this.sendEmail([to], rendered.subject, rendered.html); + await this.sendRendered(rendered, [to]); } /** @@ -141,7 +140,7 @@ export function AgentEmailMixin(Base: TBase) { html: fallbackBody, }); if (!rendered.enabled) return; - await this.sendEmail([agent.email], rendered.subject, rendered.html); + await this.sendRendered(rendered, [agent.email]); } /** @@ -172,7 +171,7 @@ export function AgentEmailMixin(Base: TBase) { html: fallbackBody, }); if (!rendered.enabled) return; - await this.sendEmail([agent.email], rendered.subject, rendered.html); + await this.sendRendered(rendered, [agent.email]); } /** @@ -200,7 +199,7 @@ export function AgentEmailMixin(Base: TBase) { html: fallbackBody, }); if (!rendered.enabled) return; - await this.sendEmail([agent.email], rendered.subject, rendered.html); + await this.sendRendered(rendered, [agent.email]); } }; } diff --git a/server/services/email/agreement.ts b/server/services/email/agreement.ts index 2080034df..9d7f88b7c 100644 --- a/server/services/email/agreement.ts +++ b/server/services/email/agreement.ts @@ -38,10 +38,9 @@ export function AgreementEmailMixin(Base: TBase) { host, ); if (!rendered.enabled) return; - await this.sendEmail( + await this.sendRendered( + rendered, [to], - rendered.subject, - rendered.html, undefined, { inspector }, ); @@ -130,10 +129,9 @@ export function AgreementEmailMixin(Base: TBase) { ); if (!rendered.enabled) return; const recipients = [to, ...ccs.filter(Boolean).filter(e => e && e !== to)]; - await this.sendEmail( + await this.sendRendered( + rendered, recipients, - rendered.subject, - rendered.html, undefined, { inspector }, ); @@ -212,10 +210,9 @@ export function AgreementEmailMixin(Base: TBase) { html: fallbackHtml, }); if (!rendered.enabled) return; - await this.sendEmail( + await this.sendRendered( + rendered, [to], - rendered.subject, - rendered.html, [ { filename: 'signed-agreement.pdf', content: signedPdfBytes.buffer as ArrayBuffer, contentType: 'application/pdf' }, { filename: 'evidence-pack.zip', content: evidenceZipBytes.buffer as ArrayBuffer, contentType: 'application/zip' }, diff --git a/server/services/email/base.ts b/server/services/email/base.ts index 4dde34644..a343af5a0 100644 --- a/server/services/email/base.ts +++ b/server/services/email/base.ts @@ -82,7 +82,33 @@ export class EmailBaseService { * otherwise use the provided fallback (keeps no-renderer unit tests working). */ protected renderOr(trigger: string, data: Record, fallback: { subject: string; html: string }, opts?: { signatureHtml?: string }): RenderResult { if (this.renderer) return this.renderer.render(trigger, data, opts); - return { subject: fallback.subject, html: fallback.html, enabled: true }; + return { trigger, subject: fallback.subject, html: fallback.html, enabled: true }; + } + + /** + * Send something the registry rendered, carrying its trigger as the + * notification class. + * + * Every domain mixin already had the same two lines — `renderOr(trigger, …)` + * then `sendEmail(to, rendered.subject, rendered.html)` — which meant ~20 + * separate places would each have had to remember to name a class. + * + * The trigger is NOT a parameter here. It rides inside `RenderResult`, + * stamped by whatever rendered it, because a second argument would have + * been a second chance to be wrong: a call site could render + * `booking-confirmation` and declare `report-ready`, and no test would see + * it. With one source there is nothing to keep in sync. + */ + protected async sendRendered( + rendered: RenderResult, + to: string[], + attachments?: Array<{ filename: string; content: ArrayBuffer | string; contentType?: string }>, + opts?: { inspector?: SenderInspector | undefined }, + ): Promise<{ delivered: boolean }> { + return this.sendEmail(to, rendered.subject, rendered.html, attachments, { + ...opts, + classId: rendered.trigger, + }); } /** Single gate for the email footer signature: requires inspector + host, @@ -132,7 +158,26 @@ export class EmailBaseService { subject: string, html: string, attachments?: Array<{ filename: string; content: ArrayBuffer | string; contentType?: string }>, - opts?: { inspector?: SenderInspector | undefined }, + opts?: { + inspector?: SenderInspector | undefined; + /** + * WHAT is being sent — a `NOTIFICATION_CLASSES` id (for registry-backed + * mail, the template trigger). + * + * The boundary used to see only an address and a rendered string, so a + * recipient-preference check placed here had nothing to check against: + * "an email to jane@x.com" cannot be matched to "Jane muted review + * requests". Enforcement itself lands with the preference table; this + * is the field that makes it possible. + * + * Prefer `sendRendered()` over passing this by hand — it derives the id + * from the same trigger that rendered the body, so the two cannot + * disagree. When absent, the send is treated as UNCLASSIFIED and + * therefore never suppressible (`isSuppressible` fails closed): an + * unclassified notification still goes out, it just cannot be muted. + */ + classId?: string; + }, ): Promise<{ delivered: boolean }> { // Free-tier pre-flight quota gate — runs BEFORE any provider request is // built. A quota block throws here, so no provider HTTP call is made diff --git a/server/services/email/concierge.ts b/server/services/email/concierge.ts index a970e29ff..8fceccb81 100644 --- a/server/services/email/concierge.ts +++ b/server/services/email/concierge.ts @@ -54,7 +54,7 @@ export function ConciergeEmailMixin(Base: TBase) { html: fallbackBody, }); if (!rendered.enabled) return; - await this.sendEmail([to], rendered.subject, rendered.html); + await this.sendRendered(rendered, [to]); } /** @@ -100,7 +100,7 @@ export function ConciergeEmailMixin(Base: TBase) { html: fallbackBody, }); if (!rendered.enabled) return; - await this.sendEmail([to], rendered.subject, rendered.html); + await this.sendRendered(rendered, [to]); } /** @@ -131,7 +131,7 @@ export function ConciergeEmailMixin(Base: TBase) { html: fallbackBody, }); if (!rendered.enabled) return; - await this.sendEmail([to], rendered.subject, rendered.html); + await this.sendRendered(rendered, [to]); } /** @@ -162,7 +162,7 @@ export function ConciergeEmailMixin(Base: TBase) { html: fallbackBody, }); if (!rendered.enabled) return; - await this.sendEmail([to], rendered.subject, rendered.html); + await this.sendRendered(rendered, [to]); } }; } diff --git a/server/services/email/inspection.ts b/server/services/email/inspection.ts index 29ed64a3b..27d0621b5 100644 --- a/server/services/email/inspection.ts +++ b/server/services/email/inspection.ts @@ -33,10 +33,9 @@ export function InspectionEmailMixin(Base: TBase) { host, ); if (!rendered.enabled) return false; - const { delivered } = await this.sendEmail( + const { delivered } = await this.sendRendered( + rendered, [to], - rendered.subject, - rendered.html, undefined, { inspector }, ); @@ -78,10 +77,9 @@ export function InspectionEmailMixin(Base: TBase) { host, ); if (!rendered.enabled) return false; - const { delivered } = await this.sendEmail( + const { delivered } = await this.sendRendered( + rendered, [to], - rendered.subject, - rendered.html, [{ filename: `${safeAddress}-report.pdf`, content: pdfBytes }], { inspector }, ); @@ -143,10 +141,13 @@ export function InspectionEmailMixin(Base: TBase) { Prefer text updates? Also text me appointment & report updates. Message & data rates may apply; reply STOP to opt out.

` : ''; - await this.sendEmail( + // The opt-in block is appended to the BODY, so the rendered result is + // spread rather than replaced — the trigger it carries is what makes + // this a `booking-confirmation` at the send boundary, and rebuilding + // the object from scratch would drop it. + await this.sendRendered( + { ...rendered, html: optinBlock ? `${rendered.html}${optinBlock}` : rendered.html }, [to], - rendered.subject, - optinBlock ? `${rendered.html}${optinBlock}` : rendered.html, attachments, { inspector }, ); diff --git a/server/services/email/transactional.ts b/server/services/email/transactional.ts index 7e5bf0fbd..9bd4e6b77 100644 --- a/server/services/email/transactional.ts +++ b/server/services/email/transactional.ts @@ -20,7 +20,7 @@ export function TransactionalEmailMixin(Base: TBase) html: fallbackBody, }); if (!rendered.enabled) return; - await this.sendEmail([to], rendered.subject, rendered.html); + await this.sendRendered(rendered, [to]); } /** @@ -35,7 +35,7 @@ export function TransactionalEmailMixin(Base: TBase) html: fallbackBody, }); if (!rendered.enabled) return; - await this.sendEmail([to], rendered.subject, rendered.html); + await this.sendRendered(rendered, [to]); } /** @@ -68,10 +68,9 @@ export function TransactionalEmailMixin(Base: TBase) host, ); if (!rendered.enabled) return; - await this.sendEmail( + await this.sendRendered( + rendered, [to], - rendered.subject, - rendered.html, undefined, { inspector }, ); @@ -124,7 +123,12 @@ export function TransactionalEmailMixin(Base: TBase) ? `

Your ${appName} workspace has used 4 of your 5 free inspections. You have one free inspection left.

${cta}` : `

Your ${appName} workspace has used your 5 free inspections — everything stays usable; subscribe to create new ones.

${cta}`; - await this.sendEmail([owner.email], subject, html); + // Hand-built HTML, not a registry template — so it names its class + // explicitly. This send was absent from the §5.0 raw-call-site audit + // because that census swept ROUTES, and this one lives inside the + // email service itself. Moving it onto a template is P3's job; making + // the boundary able to see it is this one's. + await this.sendEmail([owner.email], subject, html, undefined, { classId: 'usage-quota-warning' }); if (deps.kv) await deps.kv.put(dedupeKey, '1'); } @@ -199,7 +203,7 @@ export function TransactionalEmailMixin(Base: TBase) html: fallbackBody, }); if (!rendered.enabled) return; - await this.sendEmail([to], rendered.subject, rendered.html); + await this.sendRendered(rendered, [to]); if (deps.kv) await deps.kv.put(throttleKey, '1', { expirationTtl: 300 }); } @@ -235,7 +239,7 @@ export function TransactionalEmailMixin(Base: TBase) html, }); if (!rendered.enabled) return; - await this.sendEmail([to], rendered.subject, rendered.html); + await this.sendRendered(rendered, [to]); if (deps.kv) await deps.kv.put(throttleKey, '1', { expirationTtl: 300 }); } }; diff --git a/tests/unit/email/email-class-carrying-boundary.spec.ts b/tests/unit/email/email-class-carrying-boundary.spec.ts new file mode 100644 index 000000000..04037688b --- /dev/null +++ b/tests/unit/email/email-class-carrying-boundary.spec.ts @@ -0,0 +1,91 @@ +/** + * The send boundary has to know WHAT it is sending. + * + * `sendEmail(to, subject, html)` carries an address and a rendered string. That + * is enough to deliver and not enough to decide: "an email to jane@x.com" + * cannot be matched against "Jane muted review requests", so a preference check + * placed at the boundary would have nothing to check. Enforcement lands with + * the preference table; this spec pins the seam that makes it possible. + * + * The risk being designed out is not "someone forgets to pass a class" — it is + * "someone passes the WRONG one". ~20 mixin call sites each render a trigger + * and then send; if the class were a separate argument, each site could name a + * template it did not render, and nothing would catch it. `sendRendered()` + * derives the class from the same trigger that produced the body, so the two + * cannot disagree. + */ +import { describe, it, expect } from 'vitest'; +import { EmailBaseService } from '../../../server/services/email/base'; +import type { RenderResult } from '../../../server/lib/email-templates/types'; + +type Captured = { to: string[]; subject: string; html: string; classId?: string }; + +/** Exposes the protected seam and records what reached `sendEmail`. */ +class Probe extends EmailBaseService { + captured: Captured[] = []; + + constructor() { + super('a_real_key', 'from@x.com', 'Acme'); + } + + override async sendEmail( + to: string[], subject: string, html: string, + _attachments?: Array<{ filename: string; content: ArrayBuffer | string; contentType?: string }>, + opts?: { classId?: string }, + ): Promise<{ delivered: boolean }> { + this.captured.push({ to, subject, html, classId: opts?.classId }); + return { delivered: true }; + } + + send(rendered: RenderResult) { + return this.sendRendered(rendered, ['jane@x.com']); + } +} + +const rendered = (trigger: string, over: Partial = {}): RenderResult => + ({ trigger, subject: 'Your report is ready', html: '

hi

', enabled: true, ...over }); + +describe('class-carrying send boundary', () => { + it('carries the class to the boundary, not just an address and a string', () => { + const p = new Probe(); + p.send(rendered('report-ready')); + expect(p.captured[0].classId).toBe('report-ready'); + }); + + it('takes the class from the render result, so it cannot name a template it did not render', () => { + // The trigger is not a parameter of sendRendered — it rides inside the + // RenderResult. A caller has no argument through which to declare a + // different class from the one that produced the body. + const p = new Probe(); + p.send(rendered('payment-request', { subject: 'Invoice' })); + expect(p.captured[0].classId).toBe('payment-request'); + expect(p.captured[0].subject).toBe('Invoice'); + }); + + it('keeps the class when a caller appends to the body', () => { + // booking-confirmation spreads its result to append the SMS opt-in block. + // Rebuilding the object instead of spreading would silently drop the + // trigger and turn a classified send into an unclassified one. + const p = new Probe(); + const base = rendered('booking-confirmation'); + p.send({ ...base, html: `${base.html}

opt in

` }); + expect(p.captured[0].classId).toBe('booking-confirmation'); + expect(p.captured[0].html).toContain('opt in'); + }); + + it('still delivers subject, body and recipients unchanged', () => { + const p = new Probe(); + p.send(rendered('booking-confirmation', { subject: 'S', html: 'B' })); + expect(p.captured[0]).toMatchObject({ to: ['jane@x.com'], subject: 'S', html: 'B' }); + }); + + it('leaves the class absent for a caller that did not name one — unclassified, never silently muted', () => { + // An unclassified send must remain SENDABLE (it goes out) while being + // un-mutable, per `isSuppressible`'s fail-closed default. A boundary that + // dropped unclassified mail would turn a missing annotation into lost + // notifications. + const p = new Probe(); + p.sendEmail(['jane@x.com'], 'Ad-hoc', '

x

'); + expect(p.captured[0].classId).toBeUndefined(); + }); +}); diff --git a/tests/unit/notifications/classes.spec.ts b/tests/unit/notifications/classes.spec.ts index 3f3e07d5b..1633fc7e2 100644 --- a/tests/unit/notifications/classes.spec.ts +++ b/tests/unit/notifications/classes.spec.ts @@ -32,6 +32,9 @@ const NEVER_OFF = [ 'client-portal-login', 'agreement-request', 'agreement-signed', 'evidence-pack', 'payment-request', 'report-ready', 'report-ready-pdf', + // Not §2.0/§2.1 but the same harm: muting it means the workspace hits the + // free-tier wall with no warning. + 'usage-quota-warning', ]; /** Spec §2.2-§2.4 — the recipient's call. */ From e4f07708018f32e863c81f25c03d31cff6393454 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 31 Jul 2026 11:09:59 +0800 Subject: [PATCH 03/48] feat(notifications): the three hand-built sends become templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three sends built their own HTML: the repair-request share, the client portal sign-in link, and the free-tier quota notice. Each shipped a hardcoded slate button that ignored the company's colour and logo, could not be edited or translated, and reached the send boundary with no notification class. Being a template fixes all four at once. Two of them lived in ROUTES, which is why they were invisible: a route that builds an email is a route doing the email service's job, and no sweep of the email service can see it. The routes now own the LINK and nothing else. Three judgements are worth stating. `repair-request-share` is `required` — not because it is important, but because the recipient is an address someone typed into a box. No account, no relationship, nowhere for a preference to live. A preference is a standing choice about a stream, and one share is not a stream; the only thing "suppressible" could mean there is the operator switch, which would make a send button report success and do nothing. classes.ts now names this as the third case that earns `required`. The quota notice becomes TWO templates, not one with a variable. "One left" and "none left" are different messages, and a recipient reading a list of what we send should see both. Both are `editable: false` + `brand: 'platform'`: our message about our billing, on the same footing as password-reset. The admin editor lists only editable templates, so they correctly never appear there. Converting them exposed two layout defects, both fixed with the tests that found them. An optional block rendered an empty `

` and its margin, so "optional" could only be expressed by the caller assembling the block list — no template could declare it. And every `multiline` block invites newlines that HTML then collapsed; they now survive as `
`, inserted after escaping, so no author-supplied markup goes live. The second one was already wrong for every editable template, not just the new ones. A new gate asserts every declared variable has a preview example. It found two pre-existing holes: `agent-login-link.loginUrl` and `concierge-cancelled-agent.reason` fell back to the literal `{loginUrl}`, so the preview an admin used to check their copy rendered the CTA with a junk href. Nothing failed; it just looked wrong to whoever opened it. The class gate proved itself again before being trusted: with the four new descriptors added and no classes, it went red naming exactly the unclassified ones. The registry outgrew the file-size cap, so it splits by audience into `catalog/{system,client,agent,concierge}.ts`. Splitting rather than bumping the baseline is safe here because the scannable "everything we send" list is now NOTIFICATION_CLASSES; the registry is the copy store. Two count assertions were stale and are now honest: `email-registry`'s uniqueness check compared against a literal 20 instead of REGISTRY.length, and the route's own OpenAPI prose still claimed 17 editable templates when there were 19. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq --- scripts/file-size-baseline.json | 5 +- server/api/email-templates.ts | 6 +- server/api/portal.ts | 19 +- server/api/repair-builder.ts | 39 +- server/api/repair-builder/share.ts | 9 - server/lib/email-templates/catalog/agent.ts | 119 +++++ server/lib/email-templates/catalog/client.ts | 258 ++++++++++ .../lib/email-templates/catalog/concierge.ts | 90 ++++ server/lib/email-templates/catalog/system.ts | 95 ++++ server/lib/email-templates/layout.ts | 6 + server/lib/email-templates/registry.ts | 449 +----------------- server/lib/email-templates/renderer.ts | 14 +- server/lib/email-templates/sample-data.ts | 5 +- server/lib/mcp/openapi-snapshot.json | 2 +- server/lib/notifications/classes.ts | 19 +- server/services/email/inspection.ts | 30 ++ server/services/email/transactional.ts | 57 ++- .../unit/client-portal/portal-routes.spec.ts | 19 +- tests/unit/email/email-layout.spec.ts | 11 + tests/unit/email/email-registry.spec.ts | 31 +- tests/unit/email/email-renderer.spec.ts | 19 + .../unit/email/email-service-rendered.spec.ts | 64 +++ tests/unit/email/email-templates-api.spec.ts | 13 +- .../helpers/repair-builder-routes-harness.ts | 10 +- tests/unit/notifications/classes.spec.ts | 5 +- .../repair-builder-routes-share.spec.ts | 29 +- .../unit/usage/quota-threshold-notice.spec.ts | 4 + 27 files changed, 889 insertions(+), 538 deletions(-) create mode 100644 server/lib/email-templates/catalog/agent.ts create mode 100644 server/lib/email-templates/catalog/client.ts create mode 100644 server/lib/email-templates/catalog/concierge.ts create mode 100644 server/lib/email-templates/catalog/system.ts diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 8da661987..118d14fd6 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -36,15 +36,15 @@ "server/services/inspection/inspection-photo.service.ts": 531, "app/components/NewInspectionWizard.tsx": 530, "server/api/inspections/media-studio.ts": 530, - "server/api/portal.ts": 526, "server/services/portal-access.service.ts": 525, - "server/api/repair-builder.ts": 518, "server/api/inspections/publish.ts": 516, + "server/api/portal.ts": 515, "app/components/settings/ManagedComplianceWizard.tsx": 514, "server/api/bookings/agreement.ts": 510, "app/routes/settings-profile.tsx": 505, "server/services/inspection-request.service.ts": 501, "server/services/report-export-consumer.ts": 499, + "server/api/repair-builder.ts": 497, "app/routes/inspection-edit/action.server.ts": 496, "app/components/collab/VersionHistoryPanel.tsx": 478, "app/routes/settings-workspace.tsx": 477, @@ -53,7 +53,6 @@ "server/portal/integration.routes.ts": 472, "app/components/inspection/PeopleEditor.tsx": 457, "app/components/editor/CostItemsPanel.tsx": 449, - "server/lib/email-templates/registry.ts": 439, "app/routes/settings-schedule.tsx": 437, "app/lib/collab/results-doc-connection.ts": 435, "server/api/inspections/media.ts": 435, diff --git a/server/api/email-templates.ts b/server/api/email-templates.ts index e824a9363..0911181f4 100644 --- a/server/api/email-templates.ts +++ b/server/api/email-templates.ts @@ -1,7 +1,7 @@ /** * Email-template CRUD + preview API — GET/PUT/POST /api/admin/email-templates * - * Tenant-scoped overrides for the 17 editable registry templates. + * Tenant-scoped overrides for the editable registry templates. * Phase 3 of the email-templates feature. */ import { createRoute, z } from '@hono/zod-openapi'; @@ -84,11 +84,11 @@ const listRoute = createRoute(withMcpMetadata({ responses: { 200: { content: { 'application/json': { schema: TemplateListResponseSchema } }, - description: 'List of 17 editable templates merged with tenant overrides', + description: 'List of editable templates merged with tenant overrides', }, }, operationId: 'listEmailTemplates', - description: 'Returns all 17 editable email templates merged with the tenant\'s saved overrides. The password-reset template (non-editable) is excluded.', + description: 'Returns the editable email templates merged with the tenant\'s saved overrides. Non-editable, platform-owned templates (password reset, usage-quota notices) are excluded.', }, { scopes: ['admin'], tier: 'extended' })); // ─── GET /email-templates/{trigger} ─────────────────────────────────────── diff --git a/server/api/portal.ts b/server/api/portal.ts index 857c99e9d..7458c8888 100644 --- a/server/api/portal.ts +++ b/server/api/portal.ts @@ -324,21 +324,10 @@ const portalRoutes = portalRouter const baseUrl = getBaseUrl(c).replace(/\/$/, ''); const slug = c.get('requestedTenantSlug') || ''; const link = `${baseUrl}/portal/${slug}/auth?link=${encodeURIComponent(token)}`; - const safeLink = link.replace(/&/g, '&').replace(/"/g, '"'); - const html = ` -

-

Client Portal

-

Sign in to your portal

-

- Click the button below to access your inspections. This link expires in 15 minutes. -

-

- Open my portal -

-

If you didn't request this, you can safely ignore this email.

-
- `; - await c.var.services.email.sendEmail([email], 'Sign in to your client portal', html); + // The route mints the link; the email service renders the + // email. Built here, this was the one account-access mail + // with no tenant branding, no class and no editable copy. + await c.var.services.email.sendClientPortalLogin(email, link); } catch (err) { // Swallow send failures — never leak whether the email was known. logger.error('[portal] magic-link send failed', {}, err instanceof Error ? err : undefined); diff --git a/server/api/repair-builder.ts b/server/api/repair-builder.ts index 3e051d347..3ff5e99f1 100644 --- a/server/api/repair-builder.ts +++ b/server/api/repair-builder.ts @@ -25,7 +25,6 @@ import { shareViewRoute, sharePdfRoute, shareEmailRoute, - escapeHtmlShare, } from './repair-builder/share'; // --------------------------------------------------------------------------- @@ -479,35 +478,15 @@ const repairBuilderRoutes = createApiRouter() await checkRateLimit(c, 'book'); const baseUrl = getBaseUrl(c); - const shareUrl = `${baseUrl}/repair-request/${shareToken}`; - const safeAddress = escapeHtmlShare(propertyAddress || 'your property'); - const safeMessage = body.message - ? escapeHtmlShare(body.message).replace(/\n/g, '
') - : ''; - - const html = ` -
-

Repair Request

-

${safeAddress}

-

- A repair request list has been shared with you. Click the link below to review the items. -

- ${safeMessage ? ` -
-

Message

-

${safeMessage}

-
` : ''} -

- View repair request -

-
- `; - - await c.var.services.email.sendEmail( - [body.to], - `Repair request — ${propertyAddress || 'your property'}`, - html, - ); + + // The route owns the LINK; the email service owns the email. Building + // the HTML here is what left this send unbranded, uneditable and + // unclassified while every registry-backed send was none of those. + await c.var.services.email.sendRepairRequestShare(body.to, { + propertyAddress: propertyAddress || '', + shareUrl: `${baseUrl}/repair-request/${shareToken}`, + message: body.message, + }); return c.json({ success: true as const }, 200); }); diff --git a/server/api/repair-builder/share.ts b/server/api/repair-builder/share.ts index cd5fc75bf..59f7ec946 100644 --- a/server/api/repair-builder/share.ts +++ b/server/api/repair-builder/share.ts @@ -81,12 +81,3 @@ export const shareEmailRoute = createRoute(withMcpMetadata({ operationId: 'emailRepairRequestShare', description: 'Sends the share URL to a contractor or other recipient. Rate-limited. Report must be published.', }, { scopes: [], tier: 'extended' })); - -export function escapeHtmlShare(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} diff --git a/server/lib/email-templates/catalog/agent.ts b/server/lib/email-templates/catalog/agent.ts new file mode 100644 index 000000000..8f89106e3 --- /dev/null +++ b/server/lib/email-templates/catalog/agent.ts @@ -0,0 +1,119 @@ +import type { EmailTemplateDescriptor } from '../types'; + +/** + * Everything addressed to a partner agent: getting into their account, and + * the three referral notifications they already control per-agent. + * + * One slice of the template catalog; `registry.ts` composes the four. + */ +export const AGENT_TEMPLATES: EmailTemplateDescriptor[] = [ + { + trigger: 'agent-invite', + name: 'Partner agent invite', + category: 'agent', + editable: true, + required: true, + brand: 'tenant', + defaultSubject: '{{inspectorName}} invited you to be a partner agent', + blocks: [ + { key: 'heading', label: 'Heading', default: "You're invited", multiline: false }, + { key: 'body', label: 'Body', default: '{{inspectorName}} at {{tenantName}} has invited you to be a partner agent. Accept to see inspections for clients you refer.', multiline: true }, + { key: 'ctaLabel', label: 'Button', default: 'Accept Invitation', multiline: false }, + ], + variables: [ + { name: 'inspectorName', desc: 'Inspector\'s name' }, + { name: 'tenantName', desc: 'Workspace / company name' }, + { name: 'acceptUrl', desc: 'Invitation acceptance link' }, + ], + cta: { labelBlockKey: 'ctaLabel', urlVar: 'acceptUrl' }, + }, + + { + // Spec 3 Task 5 — agent-login-link is minted by requestMagicLoginByEmail + // (server/services/agent/magic-login.service.ts) and sent by + // EmailService.sendAgentLoginLink (server/services/email/agent.ts) for + // the core /agent-login page's magic-link fallback. Bare account-level + // sign-in with no tenant context (agents are global users), so brand: + // 'platform' — mirrors 'password-reset' above, not the tenant-branded + // 'agent-invite'/'agent-share-link' entries below. + trigger: 'agent-login-link', + name: 'Agent sign-in link', + category: 'agent', + editable: true, + required: true, + brand: 'platform', + defaultSubject: 'Sign in to your agent account', + blocks: [ + { key: 'heading', label: 'Heading', default: 'Sign in to your agent account', multiline: false }, + { key: 'body', label: 'Body', default: 'Click the button below to sign in. This link expires in 15 minutes and can only be used once.', multiline: true }, + { key: 'ctaLabel', label: 'Button', default: 'Sign in', multiline: false }, + ], + variables: [ + { name: 'loginUrl', desc: 'One-time agent sign-in link' }, + ], + cta: { labelBlockKey: 'ctaLabel', urlVar: 'loginUrl' }, + }, + + // ─── agent notifications ─────────────────────────────────────────────────── + { + trigger: 'agent-new-referral', + name: 'New referral booked', + category: 'agent', + editable: true, + required: false, + brand: 'tenant', + defaultSubject: 'New referral booked: {{propertyAddress}}', + blocks: [ + { key: 'heading', label: 'Heading', default: 'New referral booked', multiline: false }, + { key: 'body', label: 'Body', default: 'Hi {{agentName}}, an inspection at {{propertyAddress}} for {{clientName}} has been booked under your referral.', multiline: true }, + { key: 'ctaLabel', label: 'Button', default: 'Open dashboard', multiline: false }, + ], + variables: [ + { name: 'agentName', desc: 'Agent name' }, + { name: 'propertyAddress', desc: 'Property address' }, + { name: 'clientName', desc: 'Client name' }, + { name: 'dashboardUrl', desc: 'Link to the agent dashboard' }, + ], + cta: { labelBlockKey: 'ctaLabel', urlVar: 'dashboardUrl' }, + }, + + { + trigger: 'agent-report-ready', + name: 'Agent report ready', + category: 'agent', + editable: true, + required: false, + brand: 'tenant', + defaultSubject: 'Report ready: {{propertyAddress}}', + blocks: [ + { key: 'heading', label: 'Heading', default: 'Report ready to read', multiline: false }, + { key: 'body', label: 'Body', default: 'Hi {{agentName}}, the inspection report for {{propertyAddress}} has been published.', multiline: true }, + { key: 'ctaLabel', label: 'Button', default: 'View report', multiline: false }, + ], + variables: [ + { name: 'agentName', desc: 'Agent name' }, + { name: 'propertyAddress', desc: 'Property address' }, + { name: 'reportUrl', desc: 'Link to the report' }, + ], + cta: { labelBlockKey: 'ctaLabel', urlVar: 'reportUrl' }, + }, + + { + trigger: 'agent-invoice-paid', + name: 'Agent invoice paid', + category: 'agent', + editable: true, + required: false, + brand: 'tenant', + defaultSubject: 'Invoice paid: {{propertyAddress}}', + blocks: [ + { key: 'heading', label: 'Heading', default: 'Invoice paid', multiline: false }, + { key: 'body', label: 'Body', default: 'Hi {{agentName}}, the invoice for the inspection at {{propertyAddress}} has been paid in full ({{amount}}).', multiline: true }, + ], + variables: [ + { name: 'agentName', desc: 'Agent name' }, + { name: 'propertyAddress', desc: 'Property address' }, + { name: 'amount', desc: 'Amount paid (formatted)' }, + ], + }, +]; diff --git a/server/lib/email-templates/catalog/client.ts b/server/lib/email-templates/catalog/client.ts new file mode 100644 index 000000000..79b95fa35 --- /dev/null +++ b/server/lib/email-templates/catalog/client.ts @@ -0,0 +1,258 @@ +import type { EmailTemplateDescriptor } from '../types'; + +/** + * The client track — portal access, the report, money, the signed record, and + * the two links a client can share onward. + * + * One slice of the template catalog; `registry.ts` composes the four. + */ +export const CLIENT_TEMPLATES: EmailTemplateDescriptor[] = [ + { + // The client portal's magic sign-in link (`POST /api/portal/:tenant/request-link`). + // Tenant-branded, unlike the `agent-login-link` above: an agent account is + // global and signs in to us, while a client's portal belongs to one company. + trigger: 'client-portal-login', + name: 'Client portal sign-in link', + category: 'client', + editable: true, + required: true, + brand: 'tenant', + defaultSubject: 'Sign in to your client portal', + blocks: [ + { key: 'heading', label: 'Heading', default: 'Sign in to your portal', multiline: false }, + { key: 'body', label: 'Body', default: 'Click the button below to access your inspections. This link expires in 15 minutes.', multiline: true }, + { key: 'note', label: 'Note', default: "If you didn't request this, you can safely ignore this email.", multiline: true }, + { key: 'ctaLabel', label: 'Button', default: 'Open my portal', multiline: false }, + ], + variables: [ + { name: 'loginUrl', desc: 'One-time portal sign-in link' }, + ], + cta: { labelBlockKey: 'ctaLabel', urlVar: 'loginUrl' }, + }, + + { + // Sent when someone on the repair-request page types an address and presses + // send — a contractor, an agent, the other side of the transaction. + // + // `required: true` for a reason the two words above do not obviously cover: + // the recipient is an address typed into a box, with no account and no + // ongoing relationship, so there is nowhere for a preference to live. + // "Suppressible" could therefore only mean the OPERATOR switch, and that + // turns a send button into one that reports success and does nothing. + trigger: 'repair-request-share', + name: 'Repair request share', + category: 'client', + editable: true, + required: true, + brand: 'tenant', + defaultSubject: 'Repair request — {{propertyAddress}}', + blocks: [ + { key: 'heading', label: 'Heading', default: 'Repair request', multiline: false }, + { key: 'body', label: 'Body', default: 'A repair request list for {{propertyAddress}} has been shared with you. Open the link below to review the items.', multiline: true }, + // Renders nothing when the sender wrote no note — the layout drops blocks + // that resolve to empty rather than leaving a blank paragraph behind. + { key: 'message', label: 'Sender message', default: '{{message}}', multiline: true }, + { key: 'ctaLabel', label: 'Button', default: 'View repair request', multiline: false }, + ], + variables: [ + { name: 'propertyAddress', desc: 'Property address' }, + { name: 'message', desc: "The sender's optional note" }, + { name: 'shareUrl', desc: 'Link to the shared repair request' }, + ], + cta: { labelBlockKey: 'ctaLabel', urlVar: 'shareUrl' }, + }, + + { + trigger: 'agent-share-link', + name: 'Agent report share', + category: 'client', + editable: true, + required: false, + brand: 'tenant', + defaultSubject: 'Inspection report shared: {{address}}', + blocks: [ + { key: 'heading', label: 'Heading', default: 'Inspection Report Shared', multiline: false }, + { key: 'body', label: 'Body', default: 'The inspector has shared the inspection report for {{address}} with you.', multiline: true }, + { key: 'ctaLabel', label: 'Button', default: 'View Report', multiline: false }, + ], + variables: [ + { name: 'address', desc: 'Property address' }, + { name: 'reportUrl', desc: 'Link to the report' }, + ], + cta: { labelBlockKey: 'ctaLabel', urlVar: 'reportUrl' }, + }, + + { + trigger: 'report-ready', + name: 'Report ready', + category: 'client', + editable: true, + required: true, + brand: 'tenant', + defaultSubject: 'Property Inspection Report: {{address}}', + blocks: [ + { key: 'heading', label: 'Heading', default: 'Report Ready', multiline: false }, + { key: 'body', label: 'Body', default: 'The inspection for {{address}} has been completed and the report is now available.', multiline: true }, + { key: 'ctaLabel', label: 'Button', default: 'View Interactive Report', multiline: false }, + ], + variables: [ + { name: 'address', desc: 'Property address' }, + { name: 'reportUrl', desc: 'Link to the interactive report' }, + ], + cta: { labelBlockKey: 'ctaLabel', urlVar: 'reportUrl' }, + }, + + { + trigger: 'report-ready-pdf', + name: 'Report ready (PDF)', + category: 'client', + editable: true, + required: true, + brand: 'tenant', + defaultSubject: 'Property Inspection Report: {{address}}', + blocks: [ + { key: 'heading', label: 'Heading', default: 'Your Inspection Report', multiline: false }, + { key: 'body', label: 'Body', default: 'The inspection for {{address}} is complete. The full report is attached as a PDF and also available online.', multiline: true }, + { key: 'ctaLabel', label: 'Button', default: 'View Interactive Report', multiline: false }, + ], + variables: [ + { name: 'address', desc: 'Property address' }, + { name: 'reportUrl', desc: 'Link to the interactive report' }, + ], + cta: { labelBlockKey: 'ctaLabel', urlVar: 'reportUrl' }, + systemBlocks: ['attachmentManifest'], + }, + + { + trigger: 'agreement-request', + name: 'Agreement signing request', + category: 'client', + editable: true, + required: true, + brand: 'tenant', + defaultSubject: 'Please sign: {{agreementName}}', + blocks: [ + { key: 'heading', label: 'Heading', default: 'Document Ready to Sign', multiline: false }, + { key: 'body', label: 'Body', default: 'Hi {{clientName}}, you have been asked to review and sign the following agreement: {{agreementName}}.', multiline: true }, + { key: 'ctaLabel', label: 'Button', default: 'Review & Sign Agreement', multiline: false }, + ], + variables: [ + { name: 'clientName', desc: 'Client name' }, + { name: 'agreementName', desc: 'Name of the agreement to sign' }, + { name: 'signUrl', desc: 'Link to review and sign the agreement' }, + ], + cta: { labelBlockKey: 'ctaLabel', urlVar: 'signUrl' }, + }, + + { + trigger: 'payment-request', + name: 'Payment request', + category: 'client', + editable: true, + required: true, + brand: 'tenant', + defaultSubject: 'Payment request: {{amount}}', + blocks: [ + { key: 'heading', label: 'Heading', default: 'Payment Request', multiline: false }, + { key: 'body', label: 'Body', default: 'Hi {{clientName}}, your invoice is ready. The amount due is {{amount}}.', multiline: true }, + { key: 'ctaLabel', label: 'Button', default: 'View & Pay Invoice', multiline: false }, + ], + variables: [ + { name: 'clientName', desc: 'Client name' }, + { name: 'amount', desc: 'Amount due (formatted, e.g. $500.00)' }, + { name: 'payUrl', desc: 'Link to the public invoice payment page' }, + ], + cta: { labelBlockKey: 'ctaLabel', urlVar: 'payUrl' }, + }, + + { + trigger: 'message-notification', + name: 'New message', + category: 'client', + editable: true, + required: false, + brand: 'tenant', + defaultSubject: 'New message — {{propertyAddress}}', + blocks: [ + { key: 'heading', label: 'Heading', default: 'New message', multiline: false }, + { key: 'body', label: 'Body', default: 'New message from {{fromName}} regarding {{propertyAddress}}: {{snippet}}', multiline: true }, + { key: 'ctaLabel', label: 'Button', default: 'View conversation', multiline: false }, + ], + variables: [ + { name: 'fromName', desc: 'Sender name' }, + { name: 'propertyAddress', desc: 'Property address' }, + { name: 'snippet', desc: 'Short preview of the message' }, + { name: 'viewUrl', desc: 'Link to the conversation' }, + ], + cta: { labelBlockKey: 'ctaLabel', urlVar: 'viewUrl' }, + }, + + { + trigger: 'agreement-signed', + name: 'Agreement signed', + category: 'client', + editable: true, + required: true, + brand: 'tenant', + defaultSubject: 'Agreement signed — {{propertyAddress}}', + blocks: [ + { key: 'heading', label: 'Heading', default: 'Agreement signed', multiline: false }, + { key: 'body', label: 'Body', default: 'Thank you, {{clientName}}. Your inspection agreement for {{propertyAddress}} is signed and on file.', multiline: true }, + { key: 'ctaLabel', label: 'Button', default: 'View signed agreement', multiline: false }, + ], + variables: [ + { name: 'clientName', desc: 'Signer name' }, + { name: 'propertyAddress', desc: 'Property address' }, + { name: 'verifyUrl', desc: 'Public verification URL' }, + { name: 'confirmationId', desc: 'Short confirmation code' }, + { name: 'signedAtUtc', desc: 'ISO timestamp of the signature' }, + { name: 'ipAddress', desc: 'IP address recorded with the signature' }, + ], + cta: { labelBlockKey: 'ctaLabel', urlVar: 'verifyUrl' }, + systemBlocks: ['auditMetadata'], + }, + + { + trigger: 'booking-confirmation', + name: 'Booking confirmation', + category: 'client', + editable: true, + required: false, + brand: 'tenant', + defaultSubject: 'Inspection Scheduled: {{address}}', + blocks: [ + { key: 'heading', label: 'Heading', default: 'Inspection Scheduled', multiline: false }, + { key: 'body', label: 'Body', default: 'Hi {{clientName}}, your property inspection at {{address}} has been scheduled for {{date}} at {{time}}.', multiline: true }, + ], + variables: [ + { name: 'clientName', desc: 'Client name' }, + { name: 'address', desc: 'Property address' }, + { name: 'date', desc: 'Inspection date' }, + { name: 'time', desc: 'Inspection time' }, + ], + systemBlocks: ['icsHint'], + }, + + // ─── evidence / compliance ───────────────────────────────────────────────── + { + trigger: 'evidence-pack', + name: 'Evidence pack', + category: 'client', + editable: true, + required: true, + brand: 'tenant', + defaultSubject: 'Your signed agreement', + blocks: [ + { key: 'heading', label: 'Heading', default: 'Your signed agreement', multiline: false }, + { key: 'body', label: 'Body', default: 'Hi {{clientName}}, your signed agreement and full evidence pack are attached to this email for your records.', multiline: true }, + { key: 'ctaLabel', label: 'Button', default: 'Verify signed agreement', multiline: false }, + ], + variables: [ + { name: 'clientName', desc: 'Client name' }, + { name: 'envelopeId', desc: 'Agreement envelope ID' }, + { name: 'verifyUrl', desc: 'Public verification URL' }, + ], + cta: { labelBlockKey: 'ctaLabel', urlVar: 'verifyUrl' }, + systemBlocks: ['attachmentManifest'], + }, +]; diff --git a/server/lib/email-templates/catalog/concierge.ts b/server/lib/email-templates/catalog/concierge.ts new file mode 100644 index 000000000..420538bd7 --- /dev/null +++ b/server/lib/email-templates/catalog/concierge.ts @@ -0,0 +1,90 @@ +import type { EmailTemplateDescriptor } from '../types'; + +/** + * The concierge booking flow: the agent books, the client confirms. + * + * One slice of the template catalog; `registry.ts` composes the four. + */ +export const CONCIERGE_TEMPLATES: EmailTemplateDescriptor[] = [ + { + trigger: 'concierge-client-confirm', + name: 'Concierge client confirm', + category: 'concierge', + editable: true, + required: false, + brand: 'tenant', + defaultSubject: 'Confirm your home inspection at {{propertyAddress}}', + blocks: [ + { key: 'heading', label: 'Heading', default: 'Confirm your inspection', multiline: false }, + { key: 'body', label: 'Body', default: '{{inspectorName}} has scheduled an inspection for {{propertyAddress}} on {{date}}. Click below to review and confirm.', multiline: true }, + { key: 'ctaLabel', label: 'Button', default: 'Review and Confirm', multiline: false }, + ], + variables: [ + { name: 'inspectorName', desc: 'Inspector name' }, + { name: 'propertyAddress', desc: 'Property address' }, + { name: 'date', desc: 'Scheduled inspection date' }, + { name: 'confirmUrl', desc: 'Confirmation link' }, + ], + cta: { labelBlockKey: 'ctaLabel', urlVar: 'confirmUrl' }, + }, + + { + trigger: 'concierge-inspector-review', + name: 'Concierge inspector review', + category: 'concierge', + editable: true, + required: false, + brand: 'tenant', + defaultSubject: 'Concierge booking awaiting your review: {{propertyAddress}}', + blocks: [ + { key: 'heading', label: 'Heading', default: 'A booking needs your review', multiline: false }, + { key: 'body', label: 'Body', default: 'A partner agent submitted an inspection booking for {{clientName}} at {{propertyAddress}} on {{date}}.', multiline: true }, + { key: 'ctaLabel', label: 'Button', default: 'Open Dashboard', multiline: false }, + ], + variables: [ + { name: 'clientName', desc: 'Client name' }, + { name: 'propertyAddress', desc: 'Property address' }, + { name: 'date', desc: 'Scheduled inspection date' }, + { name: 'reviewUrl', desc: 'Link to review the booking' }, + ], + cta: { labelBlockKey: 'ctaLabel', urlVar: 'reviewUrl' }, + }, + + { + trigger: 'concierge-confirmed-agent', + name: 'Concierge confirmed (agent)', + category: 'concierge', + editable: true, + required: false, + brand: 'tenant', + defaultSubject: 'Concierge booking confirmed: {{propertyAddress}}', + blocks: [ + { key: 'heading', label: 'Heading', default: 'Your client confirmed', multiline: false }, + { key: 'body', label: 'Body', default: '{{clientName}} has confirmed the inspection for {{propertyAddress}} on {{date}}.', multiline: true }, + ], + variables: [ + { name: 'clientName', desc: 'Client name' }, + { name: 'propertyAddress', desc: 'Property address' }, + { name: 'date', desc: 'Scheduled inspection date' }, + ], + }, + + { + trigger: 'concierge-cancelled-agent', + name: 'Concierge cancelled (agent)', + category: 'concierge', + editable: true, + required: false, + brand: 'tenant', + defaultSubject: 'Concierge booking cancelled: {{propertyAddress}}', + blocks: [ + { key: 'heading', label: 'Heading', default: 'A booking was cancelled', multiline: false }, + { key: 'body', label: 'Body', default: 'The inspector cancelled the inspection scheduled for {{propertyAddress}} on {{date}}. {{reason}}', multiline: true }, + ], + variables: [ + { name: 'propertyAddress', desc: 'Property address' }, + { name: 'date', desc: 'Scheduled inspection date' }, + { name: 'reason', desc: 'Cancellation reason' }, + ], + }, +]; diff --git a/server/lib/email-templates/catalog/system.ts b/server/lib/email-templates/catalog/system.ts new file mode 100644 index 000000000..0de85838b --- /dev/null +++ b/server/lib/email-templates/catalog/system.ts @@ -0,0 +1,95 @@ +import type { EmailTemplateDescriptor } from '../types'; + +/** + * Account recovery, workspace invitations, and our own billing notices — + * the messages that are OURS rather than a tenant's. + * + * One slice of the template catalog; `registry.ts` composes the four. + */ +export const SYSTEM_TEMPLATES: EmailTemplateDescriptor[] = [ + { + trigger: 'password-reset', + name: 'Password reset', + category: 'system', + editable: false, + required: true, + brand: 'platform', + defaultSubject: 'Reset your password', + blocks: [ + { key: 'heading', label: 'Heading', default: 'Reset your password', multiline: false }, + { key: 'body', label: 'Body', default: 'Click the button below to reset your password. This link expires in 1 hour.', multiline: true }, + { key: 'ctaLabel', label: 'Button', default: 'Reset Password', multiline: false }, + ], + variables: [ + { name: 'resetLink', desc: 'Password-reset link' }, + ], + cta: { labelBlockKey: 'ctaLabel', urlVar: 'resetLink' }, + }, + + { + trigger: 'workspace-invitation', + name: 'Workspace invitation', + category: 'system', + editable: true, + required: true, + brand: 'tenant', + defaultSubject: "You've been invited to join a workspace", + blocks: [ + { key: 'heading', label: 'Heading', default: "You're invited", multiline: false }, + { key: 'body', label: 'Body', default: "You've been invited to join the {{tenantName}} workspace. Accept the invitation to get started.", multiline: true }, + { key: 'ctaLabel', label: 'Button', default: 'Accept Invitation', multiline: false }, + ], + variables: [ + { name: 'inviteLink', desc: 'Invitation acceptance link' }, + { name: 'tenantName', desc: 'Workspace name' }, + ], + cta: { labelBlockKey: 'ctaLabel', urlVar: 'inviteLink' }, + }, + + // The two free-tier quota notices. `editable: false` + `brand: 'platform'` + // because these are OUR message to the workspace owner about OUR billing — + // the same footing as `password-reset`, not a tenant's customer-facing mail. + // They are two templates rather than one with a variable because they say + // different things: one is a heads-up, the other is a wall. + // SaaS-only (standalone has no quota); a self-hosted deployment lists them + // and never sends them — see spec §2.6b, which V4 resolves. + { + trigger: 'usage-quota-warning', + name: 'Free inspections running out', + category: 'system', + editable: false, + required: true, + brand: 'platform', + defaultSubject: 'One free inspection left', + blocks: [ + { key: 'heading', label: 'Heading', default: 'One free inspection left', multiline: false }, + { key: 'body', label: 'Body', default: 'Your {{workspaceName}} workspace has used 4 of your 5 free inspections. You have one free inspection left.', multiline: true }, + { key: 'ctaLabel', label: 'Button', default: 'Manage subscription', multiline: false }, + ], + variables: [ + { name: 'workspaceName', desc: 'Workspace / company name' }, + { name: 'billingPortalUrl', desc: 'Link to the billing portal' }, + ], + cta: { labelBlockKey: 'ctaLabel', urlVar: 'billingPortalUrl' }, + }, + + { + trigger: 'usage-quota-reached', + name: 'Free inspections used up', + category: 'system', + editable: false, + required: true, + brand: 'platform', + defaultSubject: "You've used your 5 free inspections", + blocks: [ + { key: 'heading', label: 'Heading', default: "You've used your 5 free inspections", multiline: false }, + { key: 'body', label: 'Body', default: 'Your {{workspaceName}} workspace has used all 5 free inspections. Everything you already have stays usable — subscribe to create new ones.', multiline: true }, + { key: 'ctaLabel', label: 'Button', default: 'Manage subscription', multiline: false }, + ], + variables: [ + { name: 'workspaceName', desc: 'Workspace / company name' }, + { name: 'billingPortalUrl', desc: 'Link to the billing portal' }, + ], + cta: { labelBlockKey: 'ctaLabel', urlVar: 'billingPortalUrl' }, + }, +]; diff --git a/server/lib/email-templates/layout.ts b/server/lib/email-templates/layout.ts index 7bcf8a4a8..44ffa6d34 100644 --- a/server/lib/email-templates/layout.ts +++ b/server/lib/email-templates/layout.ts @@ -25,6 +25,12 @@ export function EmailLayout(input: LayoutInput): string { const SIG_TOKEN = /\{\{\s*signature\s*\}\}/; let signaturePlaced = false; const paras = paragraphs + // A block that resolved to nothing contributes nothing. Without this, any + // template with an optional block (the sender's note on a repair-request + // share) renders a blank paragraph and its margin — so "optional" would + // have to be expressed by the caller assembling the list, not by the + // template declaring it. + .filter(p => p.trim() !== '') .map(p => { if (signatureHtml && SIG_TOKEN.test(p)) { signaturePlaced = true; diff --git a/server/lib/email-templates/registry.ts b/server/lib/email-templates/registry.ts index 47e2a6199..53d460bd1 100644 --- a/server/lib/email-templates/registry.ts +++ b/server/lib/email-templates/registry.ts @@ -1,434 +1,25 @@ import type { EmailTemplateDescriptor } from './types'; - +import { SYSTEM_TEMPLATES } from './catalog/system'; +import { CLIENT_TEMPLATES } from './catalog/client'; +import { AGENT_TEMPLATES } from './catalog/agent'; +import { CONCIERGE_TEMPLATES } from './catalog/concierge'; + +/** + * Every email template the code can send, and the copy it sends. + * + * Split by audience into `catalog/` — as one array it outgrew the file-size + * gate, and the four groups are how anyone reasons about it anyway. Order here + * is the order the admin template list shows. + * + * This is the COPY store. The list of what we send, and whether each may be + * switched off, lives in `../notifications/classes.ts`; a template with no + * class fails the build. + */ export const REGISTRY: EmailTemplateDescriptor[] = [ - // ─── system ─────────────────────────────────────────────────────────────── - { - trigger: 'password-reset', - name: 'Password reset', - category: 'system', - editable: false, - required: true, - brand: 'platform', - defaultSubject: 'Reset your password', - blocks: [ - { key: 'heading', label: 'Heading', default: 'Reset your password', multiline: false }, - { key: 'body', label: 'Body', default: 'Click the button below to reset your password. This link expires in 1 hour.', multiline: true }, - { key: 'ctaLabel', label: 'Button', default: 'Reset Password', multiline: false }, - ], - variables: [ - { name: 'resetLink', desc: 'Password-reset link' }, - ], - cta: { labelBlockKey: 'ctaLabel', urlVar: 'resetLink' }, - }, - - { - trigger: 'workspace-invitation', - name: 'Workspace invitation', - category: 'system', - editable: true, - required: true, - brand: 'tenant', - defaultSubject: "You've been invited to join a workspace", - blocks: [ - { key: 'heading', label: 'Heading', default: "You're invited", multiline: false }, - { key: 'body', label: 'Body', default: "You've been invited to join the {{tenantName}} workspace. Accept the invitation to get started.", multiline: true }, - { key: 'ctaLabel', label: 'Button', default: 'Accept Invitation', multiline: false }, - ], - variables: [ - { name: 'inviteLink', desc: 'Invitation acceptance link' }, - { name: 'tenantName', desc: 'Workspace name' }, - ], - cta: { labelBlockKey: 'ctaLabel', urlVar: 'inviteLink' }, - }, - - // ─── agent ──────────────────────────────────────────────────────────────── - { - trigger: 'agent-invite', - name: 'Partner agent invite', - category: 'agent', - editable: true, - required: true, - brand: 'tenant', - defaultSubject: '{{inspectorName}} invited you to be a partner agent', - blocks: [ - { key: 'heading', label: 'Heading', default: "You're invited", multiline: false }, - { key: 'body', label: 'Body', default: '{{inspectorName}} at {{tenantName}} has invited you to be a partner agent. Accept to see inspections for clients you refer.', multiline: true }, - { key: 'ctaLabel', label: 'Button', default: 'Accept Invitation', multiline: false }, - ], - variables: [ - { name: 'inspectorName', desc: 'Inspector\'s name' }, - { name: 'tenantName', desc: 'Workspace / company name' }, - { name: 'acceptUrl', desc: 'Invitation acceptance link' }, - ], - cta: { labelBlockKey: 'ctaLabel', urlVar: 'acceptUrl' }, - }, - - { - // Spec 3 Task 5 — agent-login-link is minted by requestMagicLoginByEmail - // (server/services/agent/magic-login.service.ts) and sent by - // EmailService.sendAgentLoginLink (server/services/email/agent.ts) for - // the core /agent-login page's magic-link fallback. Bare account-level - // sign-in with no tenant context (agents are global users), so brand: - // 'platform' — mirrors 'password-reset' above, not the tenant-branded - // 'agent-invite'/'agent-share-link' entries below. - trigger: 'agent-login-link', - name: 'Agent sign-in link', - category: 'agent', - editable: true, - required: true, - brand: 'platform', - defaultSubject: 'Sign in to your agent account', - blocks: [ - { key: 'heading', label: 'Heading', default: 'Sign in to your agent account', multiline: false }, - { key: 'body', label: 'Body', default: 'Click the button below to sign in. This link expires in 15 minutes and can only be used once.', multiline: true }, - { key: 'ctaLabel', label: 'Button', default: 'Sign in', multiline: false }, - ], - variables: [ - { name: 'loginUrl', desc: 'One-time agent sign-in link' }, - ], - cta: { labelBlockKey: 'ctaLabel', urlVar: 'loginUrl' }, - }, - - // ─── client ─────────────────────────────────────────────────────────────── - { - trigger: 'agent-share-link', - name: 'Agent report share', - category: 'client', - editable: true, - required: false, - brand: 'tenant', - defaultSubject: 'Inspection report shared: {{address}}', - blocks: [ - { key: 'heading', label: 'Heading', default: 'Inspection Report Shared', multiline: false }, - { key: 'body', label: 'Body', default: 'The inspector has shared the inspection report for {{address}} with you.', multiline: true }, - { key: 'ctaLabel', label: 'Button', default: 'View Report', multiline: false }, - ], - variables: [ - { name: 'address', desc: 'Property address' }, - { name: 'reportUrl', desc: 'Link to the report' }, - ], - cta: { labelBlockKey: 'ctaLabel', urlVar: 'reportUrl' }, - }, - - { - trigger: 'report-ready', - name: 'Report ready', - category: 'client', - editable: true, - required: true, - brand: 'tenant', - defaultSubject: 'Property Inspection Report: {{address}}', - blocks: [ - { key: 'heading', label: 'Heading', default: 'Report Ready', multiline: false }, - { key: 'body', label: 'Body', default: 'The inspection for {{address}} has been completed and the report is now available.', multiline: true }, - { key: 'ctaLabel', label: 'Button', default: 'View Interactive Report', multiline: false }, - ], - variables: [ - { name: 'address', desc: 'Property address' }, - { name: 'reportUrl', desc: 'Link to the interactive report' }, - ], - cta: { labelBlockKey: 'ctaLabel', urlVar: 'reportUrl' }, - }, - - { - trigger: 'report-ready-pdf', - name: 'Report ready (PDF)', - category: 'client', - editable: true, - required: true, - brand: 'tenant', - defaultSubject: 'Property Inspection Report: {{address}}', - blocks: [ - { key: 'heading', label: 'Heading', default: 'Your Inspection Report', multiline: false }, - { key: 'body', label: 'Body', default: 'The inspection for {{address}} is complete. The full report is attached as a PDF and also available online.', multiline: true }, - { key: 'ctaLabel', label: 'Button', default: 'View Interactive Report', multiline: false }, - ], - variables: [ - { name: 'address', desc: 'Property address' }, - { name: 'reportUrl', desc: 'Link to the interactive report' }, - ], - cta: { labelBlockKey: 'ctaLabel', urlVar: 'reportUrl' }, - systemBlocks: ['attachmentManifest'], - }, - - { - trigger: 'agreement-request', - name: 'Agreement signing request', - category: 'client', - editable: true, - required: true, - brand: 'tenant', - defaultSubject: 'Please sign: {{agreementName}}', - blocks: [ - { key: 'heading', label: 'Heading', default: 'Document Ready to Sign', multiline: false }, - { key: 'body', label: 'Body', default: 'Hi {{clientName}}, you have been asked to review and sign the following agreement: {{agreementName}}.', multiline: true }, - { key: 'ctaLabel', label: 'Button', default: 'Review & Sign Agreement', multiline: false }, - ], - variables: [ - { name: 'clientName', desc: 'Client name' }, - { name: 'agreementName', desc: 'Name of the agreement to sign' }, - { name: 'signUrl', desc: 'Link to review and sign the agreement' }, - ], - cta: { labelBlockKey: 'ctaLabel', urlVar: 'signUrl' }, - }, - - { - trigger: 'payment-request', - name: 'Payment request', - category: 'client', - editable: true, - required: true, - brand: 'tenant', - defaultSubject: 'Payment request: {{amount}}', - blocks: [ - { key: 'heading', label: 'Heading', default: 'Payment Request', multiline: false }, - { key: 'body', label: 'Body', default: 'Hi {{clientName}}, your invoice is ready. The amount due is {{amount}}.', multiline: true }, - { key: 'ctaLabel', label: 'Button', default: 'View & Pay Invoice', multiline: false }, - ], - variables: [ - { name: 'clientName', desc: 'Client name' }, - { name: 'amount', desc: 'Amount due (formatted, e.g. $500.00)' }, - { name: 'payUrl', desc: 'Link to the public invoice payment page' }, - ], - cta: { labelBlockKey: 'ctaLabel', urlVar: 'payUrl' }, - }, - - { - trigger: 'message-notification', - name: 'New message', - category: 'client', - editable: true, - required: false, - brand: 'tenant', - defaultSubject: 'New message — {{propertyAddress}}', - blocks: [ - { key: 'heading', label: 'Heading', default: 'New message', multiline: false }, - { key: 'body', label: 'Body', default: 'New message from {{fromName}} regarding {{propertyAddress}}: {{snippet}}', multiline: true }, - { key: 'ctaLabel', label: 'Button', default: 'View conversation', multiline: false }, - ], - variables: [ - { name: 'fromName', desc: 'Sender name' }, - { name: 'propertyAddress', desc: 'Property address' }, - { name: 'snippet', desc: 'Short preview of the message' }, - { name: 'viewUrl', desc: 'Link to the conversation' }, - ], - cta: { labelBlockKey: 'ctaLabel', urlVar: 'viewUrl' }, - }, - - { - trigger: 'agreement-signed', - name: 'Agreement signed', - category: 'client', - editable: true, - required: true, - brand: 'tenant', - defaultSubject: 'Agreement signed — {{propertyAddress}}', - blocks: [ - { key: 'heading', label: 'Heading', default: 'Agreement signed', multiline: false }, - { key: 'body', label: 'Body', default: 'Thank you, {{clientName}}. Your inspection agreement for {{propertyAddress}} is signed and on file.', multiline: true }, - { key: 'ctaLabel', label: 'Button', default: 'View signed agreement', multiline: false }, - ], - variables: [ - { name: 'clientName', desc: 'Signer name' }, - { name: 'propertyAddress', desc: 'Property address' }, - { name: 'verifyUrl', desc: 'Public verification URL' }, - { name: 'confirmationId', desc: 'Short confirmation code' }, - { name: 'signedAtUtc', desc: 'ISO timestamp of the signature' }, - { name: 'ipAddress', desc: 'IP address recorded with the signature' }, - ], - cta: { labelBlockKey: 'ctaLabel', urlVar: 'verifyUrl' }, - systemBlocks: ['auditMetadata'], - }, - - { - trigger: 'booking-confirmation', - name: 'Booking confirmation', - category: 'client', - editable: true, - required: false, - brand: 'tenant', - defaultSubject: 'Inspection Scheduled: {{address}}', - blocks: [ - { key: 'heading', label: 'Heading', default: 'Inspection Scheduled', multiline: false }, - { key: 'body', label: 'Body', default: 'Hi {{clientName}}, your property inspection at {{address}} has been scheduled for {{date}} at {{time}}.', multiline: true }, - ], - variables: [ - { name: 'clientName', desc: 'Client name' }, - { name: 'address', desc: 'Property address' }, - { name: 'date', desc: 'Inspection date' }, - { name: 'time', desc: 'Inspection time' }, - ], - systemBlocks: ['icsHint'], - }, - - // ─── agent notifications ─────────────────────────────────────────────────── - { - trigger: 'agent-new-referral', - name: 'New referral booked', - category: 'agent', - editable: true, - required: false, - brand: 'tenant', - defaultSubject: 'New referral booked: {{propertyAddress}}', - blocks: [ - { key: 'heading', label: 'Heading', default: 'New referral booked', multiline: false }, - { key: 'body', label: 'Body', default: 'Hi {{agentName}}, an inspection at {{propertyAddress}} for {{clientName}} has been booked under your referral.', multiline: true }, - { key: 'ctaLabel', label: 'Button', default: 'Open dashboard', multiline: false }, - ], - variables: [ - { name: 'agentName', desc: 'Agent name' }, - { name: 'propertyAddress', desc: 'Property address' }, - { name: 'clientName', desc: 'Client name' }, - { name: 'dashboardUrl', desc: 'Link to the agent dashboard' }, - ], - cta: { labelBlockKey: 'ctaLabel', urlVar: 'dashboardUrl' }, - }, - - { - trigger: 'agent-report-ready', - name: 'Agent report ready', - category: 'agent', - editable: true, - required: false, - brand: 'tenant', - defaultSubject: 'Report ready: {{propertyAddress}}', - blocks: [ - { key: 'heading', label: 'Heading', default: 'Report ready to read', multiline: false }, - { key: 'body', label: 'Body', default: 'Hi {{agentName}}, the inspection report for {{propertyAddress}} has been published.', multiline: true }, - { key: 'ctaLabel', label: 'Button', default: 'View report', multiline: false }, - ], - variables: [ - { name: 'agentName', desc: 'Agent name' }, - { name: 'propertyAddress', desc: 'Property address' }, - { name: 'reportUrl', desc: 'Link to the report' }, - ], - cta: { labelBlockKey: 'ctaLabel', urlVar: 'reportUrl' }, - }, - - { - trigger: 'agent-invoice-paid', - name: 'Agent invoice paid', - category: 'agent', - editable: true, - required: false, - brand: 'tenant', - defaultSubject: 'Invoice paid: {{propertyAddress}}', - blocks: [ - { key: 'heading', label: 'Heading', default: 'Invoice paid', multiline: false }, - { key: 'body', label: 'Body', default: 'Hi {{agentName}}, the invoice for the inspection at {{propertyAddress}} has been paid in full ({{amount}}).', multiline: true }, - ], - variables: [ - { name: 'agentName', desc: 'Agent name' }, - { name: 'propertyAddress', desc: 'Property address' }, - { name: 'amount', desc: 'Amount paid (formatted)' }, - ], - }, - - // ─── concierge ──────────────────────────────────────────────────────────── - { - trigger: 'concierge-client-confirm', - name: 'Concierge client confirm', - category: 'concierge', - editable: true, - required: false, - brand: 'tenant', - defaultSubject: 'Confirm your home inspection at {{propertyAddress}}', - blocks: [ - { key: 'heading', label: 'Heading', default: 'Confirm your inspection', multiline: false }, - { key: 'body', label: 'Body', default: '{{inspectorName}} has scheduled an inspection for {{propertyAddress}} on {{date}}. Click below to review and confirm.', multiline: true }, - { key: 'ctaLabel', label: 'Button', default: 'Review and Confirm', multiline: false }, - ], - variables: [ - { name: 'inspectorName', desc: 'Inspector name' }, - { name: 'propertyAddress', desc: 'Property address' }, - { name: 'date', desc: 'Scheduled inspection date' }, - { name: 'confirmUrl', desc: 'Confirmation link' }, - ], - cta: { labelBlockKey: 'ctaLabel', urlVar: 'confirmUrl' }, - }, - - { - trigger: 'concierge-inspector-review', - name: 'Concierge inspector review', - category: 'concierge', - editable: true, - required: false, - brand: 'tenant', - defaultSubject: 'Concierge booking awaiting your review: {{propertyAddress}}', - blocks: [ - { key: 'heading', label: 'Heading', default: 'A booking needs your review', multiline: false }, - { key: 'body', label: 'Body', default: 'A partner agent submitted an inspection booking for {{clientName}} at {{propertyAddress}} on {{date}}.', multiline: true }, - { key: 'ctaLabel', label: 'Button', default: 'Open Dashboard', multiline: false }, - ], - variables: [ - { name: 'clientName', desc: 'Client name' }, - { name: 'propertyAddress', desc: 'Property address' }, - { name: 'date', desc: 'Scheduled inspection date' }, - { name: 'reviewUrl', desc: 'Link to review the booking' }, - ], - cta: { labelBlockKey: 'ctaLabel', urlVar: 'reviewUrl' }, - }, - - { - trigger: 'concierge-confirmed-agent', - name: 'Concierge confirmed (agent)', - category: 'concierge', - editable: true, - required: false, - brand: 'tenant', - defaultSubject: 'Concierge booking confirmed: {{propertyAddress}}', - blocks: [ - { key: 'heading', label: 'Heading', default: 'Your client confirmed', multiline: false }, - { key: 'body', label: 'Body', default: '{{clientName}} has confirmed the inspection for {{propertyAddress}} on {{date}}.', multiline: true }, - ], - variables: [ - { name: 'clientName', desc: 'Client name' }, - { name: 'propertyAddress', desc: 'Property address' }, - { name: 'date', desc: 'Scheduled inspection date' }, - ], - }, - - { - trigger: 'concierge-cancelled-agent', - name: 'Concierge cancelled (agent)', - category: 'concierge', - editable: true, - required: false, - brand: 'tenant', - defaultSubject: 'Concierge booking cancelled: {{propertyAddress}}', - blocks: [ - { key: 'heading', label: 'Heading', default: 'A booking was cancelled', multiline: false }, - { key: 'body', label: 'Body', default: 'The inspector cancelled the inspection scheduled for {{propertyAddress}} on {{date}}. {{reason}}', multiline: true }, - ], - variables: [ - { name: 'propertyAddress', desc: 'Property address' }, - { name: 'date', desc: 'Scheduled inspection date' }, - { name: 'reason', desc: 'Cancellation reason' }, - ], - }, - - // ─── evidence / compliance ───────────────────────────────────────────────── - { - trigger: 'evidence-pack', - name: 'Evidence pack', - category: 'client', - editable: true, - required: true, - brand: 'tenant', - defaultSubject: 'Your signed agreement', - blocks: [ - { key: 'heading', label: 'Heading', default: 'Your signed agreement', multiline: false }, - { key: 'body', label: 'Body', default: 'Hi {{clientName}}, your signed agreement and full evidence pack are attached to this email for your records.', multiline: true }, - { key: 'ctaLabel', label: 'Button', default: 'Verify signed agreement', multiline: false }, - ], - variables: [ - { name: 'clientName', desc: 'Client name' }, - { name: 'envelopeId', desc: 'Agreement envelope ID' }, - { name: 'verifyUrl', desc: 'Public verification URL' }, - ], - cta: { labelBlockKey: 'ctaLabel', urlVar: 'verifyUrl' }, - systemBlocks: ['attachmentManifest'], - }, + ...SYSTEM_TEMPLATES, + ...AGENT_TEMPLATES, + ...CLIENT_TEMPLATES, + ...CONCIERGE_TEMPLATES, ]; const BY_TRIGGER = new Map(REGISTRY.map(d => [d.trigger, d])); diff --git a/server/lib/email-templates/renderer.ts b/server/lib/email-templates/renderer.ts index 578609bf4..f37d6dfc0 100644 --- a/server/lib/email-templates/renderer.ts +++ b/server/lib/email-templates/renderer.ts @@ -38,7 +38,7 @@ export class EmailTemplateRenderer { const ctaLabelKey = d.cta?.labelBlockKey; const paragraphs = d.blocks .filter(b => b.key !== 'heading' && b.key !== ctaLabelKey) - .map(b => blockValues.get(b.key) ?? ''); + .map(b => nl2br(blockValues.get(b.key) ?? '')); let cta: { label: string; url: string } | undefined; if (d.cta) { @@ -79,6 +79,18 @@ export class EmailTemplateRenderer { } } +/** + * Turn the newlines in a resolved paragraph into line breaks. + * + * Every `multiline: true` block invites an author — or a sender writing a note + * into a form — to press Enter, and HTML would otherwise collapse it. Runs on + * text `interpolate()` has ALREADY escaped, so the only `<` left is the one + * added here; no author-supplied markup becomes live. + */ +function nl2br(s: string): string { + return s.replace(/\r?\n/g, '
'); +} + /** Reverse the HTML-entity encoding interpolate() added, so the subject is plain text (not entity-encoded). */ function unescapeEntities(s: string): string { return s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&'); diff --git a/server/lib/email-templates/sample-data.ts b/server/lib/email-templates/sample-data.ts index 32a13ceeb..1ae7ad17f 100644 --- a/server/lib/email-templates/sample-data.ts +++ b/server/lib/email-templates/sample-data.ts @@ -8,11 +8,14 @@ const EXAMPLES: Record = { viewUrl: 'https://app.example.com/messages/abc123', acceptUrl: 'https://app.example.com/accept/abc123', payUrl: 'https://app.example.com/invoice/abc123', inviteLink: 'https://app.example.com/join/abc123', resetLink: 'https://app.example.com/reset/abc123', + loginUrl: 'https://app.example.com/auth?link=abc123', shareUrl: 'https://app.example.com/repair-request/abc123', + billingPortalUrl: 'https://app.example.com/billing', clientName: 'Jordan Smith', inspectorName: 'Alex Rivera', agentName: 'Pat Lee', - tenantName: 'Acme Inspections', agreementName: 'Inspection Agreement', + tenantName: 'Acme Inspections', workspaceName: 'Acme Inspections', agreementName: 'Inspection Agreement', date: 'July 1, 2026', time: '3:00 PM', amount: '$350.00', confirmationId: 'A1B2C3', signedAtUtc: '2026-07-01T15:00:00Z', ipAddress: '203.0.113.7', fromName: 'Alex Rivera', snippet: 'Thanks — see you then!', envelopeId: 'ENV-12345', + message: 'Could you quote items 2 and 3?', reason: 'The seller asked to reschedule.', }; /** Phase 3 — sample values for every declared variable, used by template preview. */ diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json index a762f2e85..187b087d3 100644 --- a/server/lib/mcp/openapi-snapshot.json +++ b/server/lib/mcp/openapi-snapshot.json @@ -9502,7 +9502,7 @@ "body": null }, "summary": "List editable email templates", - "description": "Returns all 17 editable email templates merged with the tenant's saved overrides. The password-reset template (non-editable) is excluded." + "description": "Returns the editable email templates merged with the tenant's saved overrides. Non-editable, platform-owned templates (password reset, usage-quota notices) are excluded." }, { "operationId": "listEventTypes", diff --git a/server/lib/notifications/classes.ts b/server/lib/notifications/classes.ts index 54435007f..9984c1bf8 100644 --- a/server/lib/notifications/classes.ts +++ b/server/lib/notifications/classes.ts @@ -18,6 +18,14 @@ * account, hides money they owe or are owed, or destroys their only copy of * something they signed. It is not a synonym for "important". * + * One more case earns it, found while converting the hand-built sends: a + * ONE-OFF transmission the sender explicitly asked for, to an address they + * typed, where the recipient has no account and no ongoing relationship with + * us. A preference is a standing choice about a stream; a single share is not a + * stream, so there is no preference to express and nowhere to store one. The + * only thing "suppressible" could mean there is the operator switch — which + * would make a send button report success and do nothing. + * * The authority for each value is the inventory in * `docs/superpowers/specs/2026-07-31-notification-preferences-design.md` §2. * `classes.test.ts` makes that authority executable: every class must be placed @@ -46,8 +54,6 @@ export const NOTIFICATION_CLASSES: NotificationClass[] = [ { id: 'workspace-invitation', label: 'Workspace invitation', category: 'transactional', required: true, channels: ['email'] }, { id: 'agent-invite', label: 'Partner agent invite', category: 'transactional', required: true, channels: ['email'] }, { id: 'agent-login-link', label: 'Agent sign-in link', category: 'transactional', required: true, channels: ['email'] }, - // No registry entry yet — the route hand-builds the HTML. P3 converts it; - // the class exists now so the gate can already see it (spec §2.0 #9). { id: 'client-portal-login', label: 'Client portal sign-in link', category: 'transactional', required: true, channels: ['email'] }, // ─── money and legal record (spec §2.1) @@ -57,6 +63,10 @@ export const NOTIFICATION_CLASSES: NotificationClass[] = [ { id: 'payment-request', label: 'Invoice', category: 'transactional', required: true, channels: ['email'] }, { id: 'report-ready', label: 'Your report is ready', category: 'transactional', required: true, channels: ['email'] }, { id: 'report-ready-pdf', label: 'Your report (PDF)', category: 'transactional', required: true, channels: ['email'] }, + // A one-off share to a typed-in address — see the third `required: true` + // case in the header. Not "important enough to force"; there is simply no + // standing relationship for a preference to attach to. + { id: 'repair-request-share', label: 'Repair request shared with you', category: 'transactional', required: true, channels: ['email'] }, // ─── the workspace can no longer do its job (spec §2.6 shape) // Warns the owner they are at / near the free-tier inspection limit. Muting @@ -66,7 +76,12 @@ export const NOTIFICATION_CLASSES: NotificationClass[] = [ // Found while converting call sites, NOT by the §5.0 route census: it is a // hand-built send INSIDE the email service, where a sweep of routes cannot // see it. Add it to the spec's §2.4b list. + // + // Two ids, not one with a variable: "you have one left" and "you have none + // left" are different messages, and a recipient reading a list of what we + // send should see both. { id: 'usage-quota-warning', label: 'Free inspections running out', category: 'operational', required: true, channels: ['email'] }, + { id: 'usage-quota-reached', label: 'Free inspections used up', category: 'operational', required: true, channels: ['email'] }, // ─── your inspection (spec §2.2) — the recipient may switch these off { id: 'booking-confirmation', label: 'Booking confirmation', category: 'transactional', required: false, channels: ['email', 'sms'] }, diff --git a/server/services/email/inspection.ts b/server/services/email/inspection.ts index 27d0621b5..7d2aac682 100644 --- a/server/services/email/inspection.ts +++ b/server/services/email/inspection.ts @@ -86,6 +86,36 @@ export function InspectionEmailMixin(Base: TBase) { return delivered; } + /** + * Shares a repair request with an address the sender typed — usually a + * contractor, an agent, or the other side of the transaction. + * + * The route built this HTML itself, hardcoding a slate button that + * ignored the company's colour and logo. Everything a recipient sees of + * the inspector's brand was missing from the one email an inspector's + * client sends on their behalf. + * + * `message` is the sender's optional note; when they wrote none, the + * template's block resolves to nothing and the layout drops it. + */ + async sendRepairRequestShare( + to: string, + args: { propertyAddress: string; shareUrl: string; message?: string | undefined }, + ): Promise { + const address = args.propertyAddress || 'your property'; + const rendered = this.renderOr('repair-request-share', { + propertyAddress: address, + shareUrl: args.shareUrl, + message: args.message ?? '', + }, { + subject: `Repair request — ${address}`, + html: `

A repair request list for ${address} has been shared with you.

+

View repair request

`, + }); + if (!rendered.enabled) return; + await this.sendRendered(rendered, [to]); + } + /** * Sends a booking confirmation email. * diff --git a/server/services/email/transactional.ts b/server/services/email/transactional.ts index 9bd4e6b77..f817ce880 100644 --- a/server/services/email/transactional.ts +++ b/server/services/email/transactional.ts @@ -23,6 +23,28 @@ export function TransactionalEmailMixin(Base: TBase) await this.sendRendered(rendered, [to]); } + /** + * The client portal's magic sign-in link. + * + * The route used to build this HTML itself and call `sendEmail` + * directly, which meant it carried no notification class and could not + * be tenant-branded, edited or translated — the same three things every + * other account-access email already had. The caller still owns the + * link (it mints the token and knows the tenant slug); everything after + * that is this method's. + */ + async sendClientPortalLogin(to: string, loginUrl: string) { + const fallbackBody = `

Click the link below to access your inspections. This link expires in 15 minutes.

+

Open my portal

+

If you didn't request this, you can safely ignore this email. Link: ${loginUrl}

`; + const rendered = this.renderOr('client-portal-login', { loginUrl }, { + subject: 'Sign in to your client portal', + html: fallbackBody, + }); + if (!rendered.enabled) return; + await this.sendRendered(rendered, [to]); + } + /** * Sends a workspace invitation email. */ @@ -83,9 +105,12 @@ export function TransactionalEmailMixin(Base: TBase) * require a subscription). Recipient is the tenant owner (same * `role: 'owner'` lookup used by autoLinkSameEmail in agent/signup.ts). * - * Copy is inline (unlike the registry-driven methods above) — this is a - * platform system notice, not a tenant-customizable transactional email, - * so there is no tenant-override surface to wire it into. + * Registry-driven like everything else, but `editable: false` + + * `brand: 'platform'`: this is OUR message about OUR billing, so a + * tenant gets the shared layout without the ability to rewrite it. The + * two thresholds are two templates rather than one with a variable — + * "one left" and "none left" are different messages, and a recipient + * reading a list of what we send should see both. * * Deduplicated via `quota-notice:{tenantId}:{n}` in KV so a retried * request — or the benign race of two concurrent creates both reading @@ -118,17 +143,21 @@ export function TransactionalEmailMixin(Base: TBase) const cta = deps.billingPortalUrl ? `

Manage subscription

` : ''; - const subject = n === 4 ? 'One free inspection left' : "You've used your 5 free inspections"; - const html = n === 4 - ? `

Your ${appName} workspace has used 4 of your 5 free inspections. You have one free inspection left.

${cta}` - : `

Your ${appName} workspace has used your 5 free inspections — everything stays usable; subscribe to create new ones.

${cta}`; - - // Hand-built HTML, not a registry template — so it names its class - // explicitly. This send was absent from the §5.0 raw-call-site audit - // because that census swept ROUTES, and this one lives inside the - // email service itself. Moving it onto a template is P3's job; making - // the boundary able to see it is this one's. - await this.sendEmail([owner.email], subject, html, undefined, { classId: 'usage-quota-warning' }); + const trigger = n === 4 ? 'usage-quota-warning' : 'usage-quota-reached'; + const rendered = this.renderOr(trigger, { + workspaceName: this.appName, + billingPortalUrl: deps.billingPortalUrl ?? '', + }, { + subject: n === 4 ? 'One free inspection left' : "You've used your 5 free inspections", + html: n === 4 + ? `

Your ${appName} workspace has used 4 of your 5 free inspections. You have one free inspection left.

${cta}` + : `

Your ${appName} workspace has used your 5 free inspections — everything stays usable; subscribe to create new ones.

${cta}`, + }); + // No `enabled` check: both descriptors are `required`, so the + // renderer cannot return a disabled result. Guarding anyway would + // imply a tenant can switch off the warning that they are about to + // lose the ability to create inspections. + await this.sendRendered(rendered, [owner.email]); if (deps.kv) await deps.kv.put(dedupeKey, '1'); } diff --git a/tests/unit/client-portal/portal-routes.spec.ts b/tests/unit/client-portal/portal-routes.spec.ts index e7617defe..292bfc64d 100644 --- a/tests/unit/client-portal/portal-routes.spec.ts +++ b/tests/unit/client-portal/portal-routes.spec.ts @@ -27,7 +27,7 @@ const SECRET = 'test-jwt-secret'; describe('portal API', () => { let testDb: BetterSQLite3Database; - let sendEmail: ReturnType; + let sendClientPortalLogin: ReturnType; async function seedInspection(id: string, overrides: Partial = {}) { await testDb.insert(schema.inspections).values({ @@ -105,7 +105,7 @@ describe('portal API', () => { function buildApp(tenantId: string | null = TENANT) { const portalSvc = new PortalService({} as D1Database, inspStub); - sendEmail = vi.fn().mockResolvedValue({ delivered: true }); + sendClientPortalLogin = vi.fn().mockResolvedValue(undefined); const app = new OpenAPIHono(); app.use('*', async (c, next) => { if (tenantId) { @@ -114,7 +114,7 @@ describe('portal API', () => { } c.set('services', { portal: portalSvc, - email: { sendEmail }, + email: { sendClientPortalLogin }, portalAccess: makePortalAccessStub(), // Hub exchange (server/api/portal.ts) now resolves the // self-retrieve role-key set via PeopleService instead of a @@ -178,9 +178,14 @@ describe('portal API', () => { expect(res.status).toBe(200); const json = await res.json(); expect(json.data.sent).toBe(true); - expect(sendEmail).toHaveBeenCalledTimes(1); - const htmlArg = sendEmail.mock.calls[0][2] as string; - expect(htmlArg).toContain('/portal/acme/auth?link='); + // The route's job is the LINK; the email body is the email service's + // (see `sendClientPortalLogin` — it renders the branded template and + // stamps the notification class). Assert the handover, not the HTML. + expect(sendClientPortalLogin).toHaveBeenCalledTimes(1); + expect(sendClientPortalLogin).toHaveBeenCalledWith( + 'a@x.com', + expect.stringContaining('/portal/acme/auth?link='), + ); }); it('POST /request-link returns 200 for an UNKNOWN email and does NOT send (no enumeration)', async () => { @@ -195,7 +200,7 @@ describe('portal API', () => { expect(res.status).toBe(200); const json = await res.json(); expect(json.data.sent).toBe(true); - expect(sendEmail).not.toHaveBeenCalled(); + expect(sendClientPortalLogin).not.toHaveBeenCalled(); }); it('POST /request-link returns 404 when the tenant slug is unresolved', async () => { diff --git a/tests/unit/email/email-layout.spec.ts b/tests/unit/email/email-layout.spec.ts index 255a0cf6f..ca0f7a1a9 100644 --- a/tests/unit/email/email-layout.spec.ts +++ b/tests/unit/email/email-layout.spec.ts @@ -13,6 +13,17 @@ describe('EmailLayout', () => { expect(html.startsWith('')).toBe(true); }); + it('drops a paragraph that resolved to nothing, rather than leaving a blank one', () => { + // A template block can be conditional in practice — `repair-request-share` + // carries the sender's optional note. Rendering `

` for the empty case + // leaves a visible gap, so the caller would have to build the block list + // dynamically and no template could ever declare an optional paragraph. + const withGap = EmailLayout({ brand, heading: 'H', paragraphs: ['Before.', '', ' ', 'After.'] }); + expect(withGap).toContain('Before.'); + expect(withGap).toContain('After.'); + expect(withGap).not.toMatch(/]*>\s*<\/p>/); + }); + it('renders the CTA button with the primary color and url', () => { const html = EmailLayout({ brand, heading: 'H', paragraphs: [], cta: { label: 'View Report', url: 'https://x/y' } }); expect(html).toContain('https://x/y'); diff --git a/tests/unit/email/email-registry.spec.ts b/tests/unit/email/email-registry.spec.ts index cb8bf497c..db2208e83 100644 --- a/tests/unit/email/email-registry.spec.ts +++ b/tests/unit/email/email-registry.spec.ts @@ -1,17 +1,24 @@ import { describe, it, expect } from 'vitest'; import { REGISTRY, getDescriptor } from '../../../server/lib/email-templates/registry'; +import { sampleDataFor } from '../../../server/lib/email-templates/sample-data'; describe('email template registry', () => { - it('has exactly 20 descriptors', () => { - expect(REGISTRY.length).toBe(20); + // A hand-maintained count is the only tripwire for a template being DELETED + // by accident — nothing else in the suite notices a shrinking registry (an + // orphaned class is legal, since a class may exist before its template does). + it('has exactly 24 descriptors — bump deliberately when adding one', () => { + expect(REGISTRY.length).toBe(24); }); it('every trigger is unique', () => { const t = REGISTRY.map(d => d.trigger); - expect(new Set(t).size).toBe(20); + expect(new Set(t).size).toBe(REGISTRY.length); }); - it('marks exactly one non-editable (platform) trigger: password-reset', () => { + it('marks exactly the platform-owned triggers non-editable', () => { + // Non-editable == "this is OUR message, on OUR footing": account recovery + // and our own billing. A tenant rewriting either would be rewriting + // something they are not the author of. const platform = REGISTRY.filter(d => !d.editable).map(d => d.trigger); - expect(platform).toEqual(['password-reset']); + expect(platform).toEqual(['password-reset', 'usage-quota-warning', 'usage-quota-reached']); }); // Which triggers are `required` is no longer asserted here. A hardcoded pair // in this file was a snapshot of the answer, and the answer was wrong: only @@ -40,6 +47,20 @@ describe('email template registry', () => { } } }); + it('every declared variable has a preview example', () => { + // `sampleDataFor` falls back to the literal `{name}` when a variable has no + // example, so the preview an admin uses to check their copy silently shows + // `{loginUrl}` where the button link should be — and the CTA renders with a + // junk href. Nothing failed; it just looked wrong to whoever opened it. + const missing: string[] = []; + for (const d of REGISTRY) { + const sample = sampleDataFor(d); + for (const v of d.variables) { + if (sample[v.name] === `{${v.name}}`) missing.push(`${d.trigger}.${v.name}`); + } + } + expect(missing).toEqual([]); + }); it('getDescriptor returns by trigger and undefined for unknown', () => { expect(getDescriptor('report-ready')?.name).toBeTruthy(); expect(getDescriptor('nope')).toBeUndefined(); diff --git a/tests/unit/email/email-renderer.spec.ts b/tests/unit/email/email-renderer.spec.ts index 6a5c6a817..3372de781 100644 --- a/tests/unit/email/email-renderer.spec.ts +++ b/tests/unit/email/email-renderer.spec.ts @@ -26,6 +26,25 @@ describe('EmailTemplateRenderer', () => { expect(r.html).not.toContain(''); expect(r.html).toContain('<script>'); }); + it('keeps line breaks a sender or template author typed', () => { + // Every `multiline: true` block invites newlines, and until now they were + // collapsed into one run-on paragraph. The break is inserted AFTER escaping, + // so it is layout chrome — an author still cannot smuggle HTML through. + const r = mk().render('repair-request-share', { + propertyAddress: '12 Elm', + message: 'Line one.\nLine two.', + shareUrl: 'https://x/s', + }); + expect(r.html).toContain('Line one.
Line two.'); + expect(r.html).not.toContain(''); + }); + + it('leaves no empty paragraph when an optional block resolves to nothing', () => { + const r = mk().render('repair-request-share', { propertyAddress: '12 Elm', shareUrl: 'https://x/s' }); + expect(r.html).toContain('12 Elm'); + expect(r.html).not.toMatch(/]*>\s*<\/p>/); + }); + it('throws for an unknown trigger', () => { expect(() => mk().render('nope', {})).toThrow(); }); diff --git a/tests/unit/email/email-service-rendered.spec.ts b/tests/unit/email/email-service-rendered.spec.ts index 7c647a36b..61e692043 100644 --- a/tests/unit/email/email-service-rendered.spec.ts +++ b/tests/unit/email/email-service-rendered.spec.ts @@ -64,3 +64,67 @@ describe('EmailService rendered path', () => { expect(body.html).not.toContain('inspection.ics'); }); }); + +/** + * The two sends the ROUTES used to build by hand. + * + * Each one shipped a hardcoded slate button (`#0f172a`) that ignored the + * company's colour and logo, could not be edited or translated, and reached the + * send boundary with no notification class. Being a template is what fixes all + * four at once, so these assert all four. + */ +describe('sends converted off hand-built HTML', () => { + /** Captures what reached the boundary, including the class the routes lacked. */ + class Probe extends EmailService { + captured: Array<{ to: string[]; subject: string; html: string; classId?: string }> = []; + override async sendEmail( + to: string[], subject: string, html: string, + _attachments?: Array<{ filename: string; content: ArrayBuffer | string; contentType?: string }>, + opts?: { classId?: string }, + ): Promise<{ delivered: boolean }> { + this.captured.push({ to, subject, html, classId: opts?.classId }); + return { delivered: true }; + } + } + const probe = () => new Probe('re_test', 'reports@acme.com', 'Acme', undefined, renderer); + + it('client portal sign-in: tenant-branded, carries the link and its class', async () => { + const p = probe(); + await p.sendClientPortalLogin('a@x.com', 'https://x/portal/acme/auth?link=tok'); + expect(p.captured[0].classId).toBe('client-portal-login'); + expect(p.captured[0].subject).toBe('Sign in to your client portal'); + expect(p.captured[0].html).toContain('https://x/portal/acme/auth?link=tok'); + // Tenant brand, not the platform's — a client's portal belongs to one company. + expect(p.captured[0].html).toContain('Acme'); + expect(p.captured[0].html).not.toContain('#0f172a;">Open my portal'); + }); + + it('repair-request share: subject carries the address, body the note and the link', async () => { + const p = probe(); + await p.sendRepairRequestShare('contractor@x.com', { + propertyAddress: '12 Elm St', + shareUrl: 'https://x/repair-request/tok', + message: 'Please quote items 2 and 3.\nThanks.', + }); + expect(p.captured[0].classId).toBe('repair-request-share'); + expect(p.captured[0].subject).toBe('Repair request — 12 Elm St'); + expect(p.captured[0].html).toContain('https://x/repair-request/tok'); + // The sender's newline survives — they typed it into a textarea. + expect(p.captured[0].html).toContain('Please quote items 2 and 3.
Thanks.'); + }); + + it('repair-request share with no note leaves no empty block behind', async () => { + const p = probe(); + await p.sendRepairRequestShare('contractor@x.com', { + propertyAddress: '12 Elm St', + shareUrl: 'https://x/repair-request/tok', + }); + expect(p.captured[0].html).not.toMatch(/]*>\s*<\/p>/); + }); + + it('falls back to "your property" rather than a subject ending in a dash', async () => { + const p = probe(); + await p.sendRepairRequestShare('contractor@x.com', { propertyAddress: '', shareUrl: 'https://x/s' }); + expect(p.captured[0].subject).toBe('Repair request — your property'); + }); +}); diff --git a/tests/unit/email/email-templates-api.spec.ts b/tests/unit/email/email-templates-api.spec.ts index 44810d636..5516d1caa 100644 --- a/tests/unit/email/email-templates-api.spec.ts +++ b/tests/unit/email/email-templates-api.spec.ts @@ -16,6 +16,7 @@ vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; import emailTemplateRoutes from '../../../server/api/email-templates'; +import { REGISTRY } from '../../../server/lib/email-templates/registry'; const TENANT_ID = '00000000-0000-0000-0000-000000000001'; @@ -62,14 +63,20 @@ describe('GET /api/admin/email-templates', () => { vi.clearAllMocks(); }); - it('returns 200 with 19 items, no password-reset, correct fields', async () => { + it('lists every editable template and no platform-owned one, with correct fields', async () => { const app = buildApp(); const res = await app.request('/api/admin/email-templates', {}, { DB: {} }); expect(res.status).toBe(200); const body = await res.json() as { success: boolean; data: Array<{ trigger: string; name: string; required: boolean; enabled: boolean; isCustomized: boolean; subject: string; category: string }> }; expect(body.success).toBe(true); - expect(body.data).toHaveLength(19); - expect(body.data.every(t => t.trigger !== 'password-reset')).toBe(true); + expect(body.data).toHaveLength(21); + // The editor lists what a TENANT may rewrite. Account recovery and our + // own billing notices are ours, so they must never appear here — assert + // the rule, not just its first instance. + const listed = new Set(body.data.map(t => t.trigger)); + for (const d of REGISTRY.filter(x => !x.editable)) { + expect(listed.has(d.trigger), `${d.trigger} is not editable and must not be listed`).toBe(false); + } for (const item of body.data) { expect(typeof item.trigger).toBe('string'); expect(typeof item.name).toBe('string'); diff --git a/tests/unit/helpers/repair-builder-routes-harness.ts b/tests/unit/helpers/repair-builder-routes-harness.ts index eebb777af..cd9896d40 100644 --- a/tests/unit/helpers/repair-builder-routes-harness.ts +++ b/tests/unit/helpers/repair-builder-routes-harness.ts @@ -208,7 +208,7 @@ export function makeShareDb(inspResult: unknown) { export function makeShareServices(overrides: { getByShareToken?: ReturnType; creditTotal?: ReturnType; - sendEmail?: ReturnType; + sendRepairRequestShare?: ReturnType; } = {}) { return { portalAccess: { resolveToken: vi.fn().mockResolvedValue(null) }, @@ -227,7 +227,7 @@ export function makeShareServices(overrides: { assertCanEdit: vi.fn().mockResolvedValue(undefined), }, email: { - sendEmail: overrides.sendEmail ?? vi.fn().mockResolvedValue({ delivered: true }), + sendRepairRequestShare: overrides.sendRepairRequestShare ?? vi.fn().mockResolvedValue(undefined), }, }; } @@ -251,20 +251,20 @@ export function buildShareApp(opts: { rrResult?: { request: typeof SHARE_RR; items: typeof SHARE_ITEMS } | null; inspResult?: typeof SHARE_INSP_PUBLISHED | typeof SHARE_INSP_UNPUBLISHED | null; creditTotalResult?: number; - sendEmail?: ReturnType; + sendRepairRequestShare?: ReturnType; browserBinding?: unknown; }) { const { rrResult = { request: SHARE_RR, items: SHARE_ITEMS }, inspResult = SHARE_INSP_PUBLISHED, creditTotalResult = 5000, - sendEmail, + sendRepairRequestShare, browserBinding, } = opts; const getByShareToken = vi.fn().mockResolvedValue(rrResult); const creditTotal = vi.fn().mockResolvedValue(creditTotalResult); - const svc = makeShareServices({ getByShareToken, creditTotal, sendEmail }); + const svc = makeShareServices({ getByShareToken, creditTotal, sendRepairRequestShare }); (mockDrizzle as unknown as ReturnType).mockReturnValue( makeShareDb(inspResult), diff --git a/tests/unit/notifications/classes.spec.ts b/tests/unit/notifications/classes.spec.ts index 1633fc7e2..852b3fd0f 100644 --- a/tests/unit/notifications/classes.spec.ts +++ b/tests/unit/notifications/classes.spec.ts @@ -34,7 +34,10 @@ const NEVER_OFF = [ 'report-ready', 'report-ready-pdf', // Not §2.0/§2.1 but the same harm: muting it means the workspace hits the // free-tier wall with no warning. - 'usage-quota-warning', + 'usage-quota-warning', 'usage-quota-reached', + // A one-off share to a typed-in address: no account, no relationship, so no + // preference can exist. See the third `required: true` case in classes.ts. + 'repair-request-share', ]; /** Spec §2.2-§2.4 — the recipient's call. */ diff --git a/tests/unit/repair/repair-builder-routes-share.spec.ts b/tests/unit/repair/repair-builder-routes-share.spec.ts index 12314adb4..fc743f4ba 100644 --- a/tests/unit/repair/repair-builder-routes-share.spec.ts +++ b/tests/unit/repair/repair-builder-routes-share.spec.ts @@ -156,9 +156,13 @@ describe('POST /api/public/repair-request/share/:shareToken/email', () => { expect(res.status).toBe(400); }); - it('200 on published report with valid email — calls sendEmail', async () => { - const sendEmail = vi.fn().mockResolvedValue({ delivered: true }); - const { app, svc } = buildShareApp({ sendEmail }); + it('200 on published report with valid email — hands the address, link and note to the email service', async () => { + // The route no longer builds HTML: it owns the share LINK and passes the + // facts. What the recipient actually sees is the email service's job + // (`sendRepairRequestShare` → the branded `repair-request-share` + // template), tested where that decision lives. + const sendRepairRequestShare = vi.fn().mockResolvedValue(undefined); + const { app, svc } = buildShareApp({ sendRepairRequestShare }); const res = await app.request('/api/public/repair-request/share/share-tok-abc/email', { method: 'POST', @@ -169,16 +173,19 @@ describe('POST /api/public/repair-request/share/:shareToken/email', () => { const body = await res.json() as { success: boolean }; expect(body.success).toBe(true); - expect(svc.email.sendEmail).toHaveBeenCalledWith( - ['contractor@example.com'], - expect.stringContaining('123 Main St'), - expect.any(String), + expect(svc.email.sendRepairRequestShare).toHaveBeenCalledWith( + 'contractor@example.com', + expect.objectContaining({ + propertyAddress: '123 Main St', + shareUrl: expect.stringContaining('/repair-request/share-tok-abc'), + message: 'Please review.', + }), ); }); it('200 on published report with no optional message', async () => { - const sendEmail = vi.fn().mockResolvedValue({ delivered: true }); - const { app } = buildShareApp({ sendEmail }); + const sendRepairRequestShare = vi.fn().mockResolvedValue(undefined); + const { app, svc } = buildShareApp({ sendRepairRequestShare }); const res = await app.request('/api/public/repair-request/share/share-tok-abc/email', { method: 'POST', @@ -186,5 +193,9 @@ describe('POST /api/public/repair-request/share/:shareToken/email', () => { body: JSON.stringify({ to: 'contractor@example.com' }), }); expect(res.status).toBe(200); + expect(svc.email.sendRepairRequestShare).toHaveBeenCalledWith( + 'contractor@example.com', + expect.objectContaining({ message: undefined }), + ); }); }); diff --git a/tests/unit/usage/quota-threshold-notice.spec.ts b/tests/unit/usage/quota-threshold-notice.spec.ts index bffac7cef..d3fc3c910 100644 --- a/tests/unit/usage/quota-threshold-notice.spec.ts +++ b/tests/unit/usage/quota-threshold-notice.spec.ts @@ -90,6 +90,10 @@ describe('sendQuotaThresholdNotice', () => { expect(body.to).toEqual(['owner4@example.com']); expect(body.subject).toBe('One free inspection left'); expect(body.html).toContain('one free inspection left'); + // It goes through the shared branded layout now, not a bare `

` — + // the whole point of moving it onto a registry template. + expect(body.html).toContain(''); + expect(body.html).toContain('https://billing.example.com'); }); it('emails the tenant owner with the 5/5 "cap reached" copy', async () => { From fe60d47fa8d6ac287626c1086212857cb49255c5 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 31 Jul 2026 11:37:27 +0800 Subject: [PATCH 04/48] refactor(sms): one gate chain, and the two test sends now run it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three paths carried their own copy of the SMS gate chain: the real send, the template test-send, and the settings test-connection. They did not BYPASS the gates — that would be the obvious bug and it is not the one that was present. Each had a copy, and a copy only has the gates someone remembered to add to it. That is not theoretical, and it is why this is worth doing. When the STOP-revocation check was added it landed in exactly one of the three. Nobody skipped a step; the other two were not there to receive it. Both test paths would send to a number that had texted STOP, and report success. Two failing tests proved that before the fix, one per path. The chain now lives in `lib/sms/send-gate.ts` and a caller declares a `purpose`. `test` names exactly one exemption — express consent, because there is no contact to hold any, so requiring it would mean no test send could ever succeed. It is NOT exempt from revocation: honoring STOP does not depend on the basis the first message was sent under, and it does not care that this one is a test. Revocation for a test send matches the NUMBER, since there is no contact. That match is normalized exactly the way the inbound STOP webhook normalizes on read — if the two disagreed, a revocation could be recorded against a contact this check would then fail to find, and the revocation would exist while doing nothing. A test pins it by seeding the contact as `(555) 999-1234` and texting `+15559991234`; breaking the normalization makes only that test go red. This also closes a hole in the real path. Revocation used to be checked only when the log carried a contact id; a log without one skipped the check entirely. It now falls through to the number match, so an implied-basis recipient whose number texted STOP is refused rather than texted. All 187 automation tests pass unchanged, so the paths that did have a contact id behave exactly as before. `rawDb` leaves `SendOneSmsArgs`: it existed only to hand SmsConsentService a raw binding, and the gate reads consent through drizzle. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq --- server/api/inspections/send-sms.ts | 1 - server/api/message-templates.ts | 49 ++--- server/api/sms.ts | 40 ++-- server/lib/sms/send-gate.ts | 177 ++++++++++++++++++ server/services/automation/send-one-sms.ts | 127 +++++-------- server/services/automation/sms.ts | 1 - .../message-template-sms-test-send.spec.ts | 62 ++++++ tests/unit/messaging/sms-api.spec.ts | 45 +++++ tests/unit/messaging/sms-send-gate.spec.ts | 156 +++++++++++++++ 9 files changed, 528 insertions(+), 130 deletions(-) create mode 100644 server/lib/sms/send-gate.ts create mode 100644 tests/unit/messaging/sms-send-gate.spec.ts diff --git a/server/api/inspections/send-sms.ts b/server/api/inspections/send-sms.ts index a609d1190..e2d519eee 100644 --- a/server/api/inspections/send-sms.ts +++ b/server/api/inspections/send-sms.ts @@ -159,7 +159,6 @@ const sendSmsRoutes = createApiRouter() await sendOneSms({ db, - rawDb, log, inspection: flushInspection, tenant, diff --git a/server/api/message-templates.ts b/server/api/message-templates.ts index 217fbf3d1..e57727e0f 100644 --- a/server/api/message-templates.ts +++ b/server/api/message-templates.ts @@ -1,5 +1,4 @@ import { createRoute, z } from '@hono/zod-openapi'; -import { eq } from 'drizzle-orm'; import { createApiRouter } from '../lib/openapi-router'; import { requireRole } from '../lib/middleware/rbac'; import { withMcpMetadata } from '../lib/route-metadata-standards'; @@ -10,10 +9,9 @@ import { buildTenantEmailService } from '../lib/email/build-email-service'; import { PlanQuotaGuard, readTenantTier } from '../features/plan-quota/guard'; import { loadProviderForTenant } from '../lib/sms/resolve-twilio'; import { normalizeE164 } from '../lib/sms/phone'; -import { managedSendAllowed } from '../lib/sms/managed-send-gate'; +import { smsSendGate } from '../lib/sms/send-gate'; import { maybeMetering } from '../services/metering.service'; import { currentPeriodKey } from '../lib/usage/period'; -import { tenantConfigs } from '../lib/db/schema'; import { getDrizzle } from '../lib/route-helpers'; import { CreateMessageTemplateSchema, UpdateMessageTemplateSchema, PreviewMessageTemplateSchema, @@ -156,33 +154,26 @@ const messageTemplateRoutes = createApiRouter() const normalized = normalizeE164(to); if (!normalized) return c.json({ success: false, error: 'That phone number could not be parsed.' }, 200); - // Mirrors server/api/sms.ts POST /sms/test exactly: managed-compliance - // gate, then free-tier pre-flight, both BEFORE any provider call — a - // template test-send is a real send and must not bypass either the - // compliance gate or the quota cap the standalone SMS test endpoint - // already enforces. + // ONE gate chain, shared with the real send path and the settings + // test-connection (`lib/sms/send-gate.ts`). This route used to carry + // its own copy, which is why it never received the STOP-revocation + // check: `purpose: 'test'` now states the single exemption it has + // (express consent — there is no contact to hold any) instead of + // being exempt from whatever nobody remembered to copy across. const db = getDrizzle(c); - let cfgRow: { smsMode: string; smsByoProvider: string | null } | null | undefined; - try { - cfgRow = await db.select({ smsMode: tenantConfigs.smsMode, smsByoProvider: tenantConfigs.smsByoProvider }) - .from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); - } catch { cfgRow = null; } - const smsMode = cfgRow?.smsMode ?? 'platform'; - - const gate = await managedSendAllowed(db, c.env, tenantId, smsMode); - if (!gate.allowed) { - return c.json({ success: false, error: gate.reason ?? 'managed_not_approved' }, 200); - } - - // Free-tier pre-flight (2026-07) — platform-mode sends count against - // the lifetime sms cap; 'own' is BYO and uncapped. `tenantTier` is not - // populated by session-context on this JWT-authenticated route, so - // fall back to a one-shot tier lookup (mirrors sms.ts / di.ts). - if (c.var.profile.hasUsageQuota && smsMode !== 'own') { - const quotaGuard = new PlanQuotaGuard(c.env.DB, { enforced: true, billingPortalUrl: c.var.profile.billingPortalUrl }); - const tier = c.get('tenantTier') ?? await readTenantTier(c.env.DB, tenantId); - await quotaGuard.checkMessagingQuota(tenantId, tier, 'sms'); - } + const quotaGuard = c.var.profile.hasUsageQuota + ? new PlanQuotaGuard(c.env.DB, { enforced: true, billingPortalUrl: c.var.profile.billingPortalUrl }) + : undefined; + const gate = await smsSendGate({ + db, tenantId, to: normalized, purpose: 'test', env: c.env, + // `tenantTier` is not populated by session-context on this + // JWT-authenticated route, so fall back to a one-shot lookup. + ...(quotaGuard + ? { quota: { guard: quotaGuard, tier: c.get('tenantTier') ?? await readTenantTier(c.env.DB, tenantId) } } + : {}), + }); + if (!gate.allowed) return c.json({ success: false, error: gate.reason }, 200); + const smsMode = gate.smsMode; const resolved = await loadProviderForTenant(c.env, tenantId); if (!resolved) return c.json({ success: false, error: 'SMS is not configured.' }, 200); diff --git a/server/api/sms.ts b/server/api/sms.ts index 22f544775..8f422ac81 100644 --- a/server/api/sms.ts +++ b/server/api/sms.ts @@ -40,7 +40,7 @@ import { normalizeE164 } from '../lib/sms/phone'; import { loadProviderForTenant, resolveTwilioSource } from '../lib/sms/resolve-twilio'; import { resolveComplianceProvider } from '../lib/sms/resolve-compliance-provider'; import { recordIntegrationTest } from '../lib/integration-test-results'; -import { managedSendAllowed } from '../lib/sms/managed-send-gate'; +import { smsSendGate } from '../lib/sms/send-gate'; import { PlanQuotaGuard, readTenantTier } from '../features/plan-quota/guard'; import { complianceWebhookUrl } from '../lib/sms/compliance-webhook'; import { getBaseUrl } from '../lib/url'; @@ -502,27 +502,27 @@ export const smsAdminRoutes = createApiRouter() .from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); } catch { cfgRow = null; } const smsProvider = cfgRow?.smsByoProvider ?? 'twilio'; - const smsMode = cfgRow?.smsMode ?? 'platform'; - const gate = await managedSendAllowed(db, c.env, tenantId, smsMode); - if (!gate.allowed) { - logger.info('sms.test_send: blocked by managed compliance gate', { tenantId, reason: gate.reason }); - await recordIntegrationTest(db, { tenantId, target: 'sms', provider: smsProvider, ok: false, detail: gate.reason ?? 'managed_not_approved', testedByUserId }).catch(() => {}); - return c.json({ success: false, error: gate.reason ?? 'managed_not_approved' }, 200); - } - // Free-tier pre-flight (2026-07) — a free tenant's platform-mode sends - // (any mode except 'own', which is BYO and uncapped) count against the - // lifetime sms cap. Runs alongside (not replacing) the managed-compliance - // gate above, and BEFORE any provider call — a quota block never spends - // a provider request or a meter record. `tenantTier` is not populated by - // session-context on this JWT-authenticated route (only the public/ - // fixed-tenant tenant-routing resolvers set it), so fall back to a - // one-shot tier lookup. - if (c.var.profile.hasUsageQuota && smsMode !== 'own') { - const quotaGuard = new PlanQuotaGuard(c.env.DB, { enforced: true, billingPortalUrl: c.var.profile.billingPortalUrl }); - const tier = c.get('tenantTier') ?? await readTenantTier(c.env.DB, tenantId); - await quotaGuard.checkMessagingQuota(tenantId, tier, 'sms'); + // ONE gate chain, shared with the real send path and the template + // test-send (`lib/sms/send-gate.ts` — the reasoning lives there). This + // route carried its own copy, which is how it came to be the only one + // of the three with no STOP-revocation check. `tenantTier` is unset by + // session-context here, so fall back to a one-shot lookup. + const quotaGuard = c.var.profile.hasUsageQuota + ? new PlanQuotaGuard(c.env.DB, { enforced: true, billingPortalUrl: c.var.profile.billingPortalUrl }) + : undefined; + const gate = await smsSendGate({ + db, tenantId, to: normalized, purpose: 'test', env: c.env, + ...(quotaGuard + ? { quota: { guard: quotaGuard, tier: c.get('tenantTier') ?? await readTenantTier(c.env.DB, tenantId) } } + : {}), + }); + if (!gate.allowed) { + logger.info('sms.test_send: blocked', { tenantId, reason: gate.reason }); + await recordIntegrationTest(db, { tenantId, target: 'sms', provider: smsProvider, ok: false, detail: gate.reason, testedByUserId }).catch(() => {}); + return c.json({ success: false, error: gate.reason }, 200); } + const smsMode = gate.smsMode; // Use the provider-aware loader so BYO Telnyx tenants route to TelnyxProvider. // Twilio tenants: same logic as before (loadProviderForTenant → resolveTwilio). diff --git a/server/lib/sms/send-gate.ts b/server/lib/sms/send-gate.ts new file mode 100644 index 000000000..9cac4ae65 --- /dev/null +++ b/server/lib/sms/send-gate.ts @@ -0,0 +1,177 @@ +/** + * The one gate chain every outbound SMS passes through. + * + * There used to be three copies: the real send path (`sendOneSms`), the + * template test send, and the settings "test connection" send. They did not + * BYPASS the gates — that would be the obvious bug and it is not the one that + * was present. They each carried their own copy of the chain, and a copy only + * has the gates someone remembered to add to it. + * + * That is not theoretical. When the STOP-revocation check was added, it landed + * in exactly one of the three. Nobody skipped a step; the other two simply were + * not there to receive it. A copied chain does this every time, and it would + * have done it again for the next gate. + * + * So the chain lives here once, and a caller declares its `purpose` instead of + * declaring nothing and being exempt from whatever was not copied. The + * exemptions are stated below, in one place, where they can be argued with. + * + * WHAT STAYS WITH THE CALLER: writing `automation_logs` rows, resolving the + * body template, and the provider call itself. This function decides whether + * the send may happen; it does not perform it. + */ +import { and, eq, desc } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { contacts, smsConsentLog, tenantConfigs } from '../db/schema'; +import { managedSendAllowed, type ManagedSendGateEnv } from './managed-send-gate'; +import { requiresExpressSmsConsent } from './consent-basis'; +import { normalizeE164 } from './phone'; +import type { RoleKind } from '../people/role-kinds'; +import type { PlanQuotaGuard } from '../../features/plan-quota/guard'; +import { logger } from '../logger'; + +/** + * Why this message is being sent — and therefore which gates it is exempt from. + * + * - `notification` — a real message to a real recipient. Every gate applies. + * - `test` — an operator sending to a number they control, to check that SMS + * works at all. Exempt from the EXPRESS-CONSENT requirement only, and only + * because there is no contact to hold consent: nothing exists to consult, so + * requiring it would mean no test send could ever succeed. + * + * `test` is NOT exempt from revocation. Honoring STOP does not depend on the + * basis the first message was sent under, and it does not care that this one + * is a test — a tenant testing against a number that texted STOP should be + * told so, not quietly sent to. + */ +export type SmsPurpose = 'notification' | 'test'; + +export type SmsGateOutcome = + | { allowed: true; smsMode: string; companyPhone: string | null; reviewUrl: string | null } + | { allowed: false; reason: string }; + +export interface SmsGateArgs { + // Callers pass tenant-scoped drizzle handles with different schema maps; + // this only touches a handful of tables by name. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + db: DrizzleD1Database; + tenantId: string; + /** Destination number, in whatever shape the caller holds it. */ + to: string; + purpose: SmsPurpose; + /** + * The contact this message is addressed to, when one is known. + * + * A notification knows it (stamped on the log at enqueue). A test send does + * not, so revocation falls back to matching the NUMBER — which is the same + * match the inbound STOP webhook makes when it records the revocation, and + * therefore finds the same rows. + */ + contactId?: string | null; + /** Consent basis for the recipient. Only consulted when `purpose` is `notification`. */ + roleKind?: RoleKind; + env?: ManagedSendGateEnv | undefined; + /** Absent ⇒ no quota enforcement (standalone, BYO, or a non-quota deployment). */ + quota?: { guard: PlanQuotaGuard; tier: string } | undefined; +} + +/** Latest consent action for a contact, or null when it has no ledger. */ +async function latestConsent( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + db: DrizzleD1Database, + tenantId: string, + contactId: string, +): Promise<'granted' | 'revoked' | null> { + const row = await db.select({ action: smsConsentLog.action }).from(smsConsentLog) + .where(and(eq(smsConsentLog.tenantId, tenantId), eq(smsConsentLog.contactId, contactId))) + .orderBy(desc(smsConsentLog.createdAt)).limit(1).get(); + return (row?.action as 'granted' | 'revoked' | undefined) ?? null; +} + +/** + * Contacts in this tenant whose number is the one being texted. + * + * Matches on the NORMALIZED phone, because stored phones may not be — the + * inbound STOP webhook normalizes on read for exactly this reason, and if the + * two matchers disagreed, a revocation could be recorded against a contact this + * check would then fail to find. + */ +async function contactIdsForPhone( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + db: DrizzleD1Database, + tenantId: string, + to: string, +): Promise { + const target = normalizeE164(to); + if (!target) return []; + const rows = await db.select({ id: contacts.id, phone: contacts.phone }) + .from(contacts).where(eq(contacts.tenantId, tenantId)).all(); + return rows.filter((r) => normalizeE164(r.phone) === target).map((r) => r.id); +} + +export async function smsSendGate(args: SmsGateArgs): Promise { + const { db, tenantId, to, purpose, contactId, roleKind, env, quota } = args; + + // A tenant with no config row is 'platform' — the same default all three + // chains already used. Wrapped rather than `.catch()`-chained because some + // drizzle handles return a thenable-only builder from `.get()`. + let cfg: { smsMode: string; companyPhone: string | null; reviewUrl: string | null } | null | undefined; + try { + cfg = await db.select({ + smsMode: tenantConfigs.smsMode, + companyPhone: tenantConfigs.companyPhone, + reviewUrl: tenantConfigs.reviewUrl, + }).from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); + } catch { cfg = null; } + const smsMode = cfg?.smsMode ?? 'platform'; + + // ── Consent. Two DIFFERENT rules, and conflating them is what let a revoked + // agent keep receiving texts. + // + // REVOCATION BINDS EVERYONE. It does not depend on the basis the first + // message was sent under — it is the one CTIA rule that is universal, and + // both published documents warrant it (ToS: STOP is honored "for all + // outbound recipients"; privacy notice: business counterparties keep STOP + // available). + // + // EXPRESS CONSENT is required only of consumers (client kind). Agents, + // other business counterparties and staff are implied (D5 + A3.2); the + // absence of a granted row is not a reason to withhold from them. + const consultable = contactId ? [contactId] : await contactIdsForPhone(db, tenantId, to); + for (const id of consultable) { + if (await latestConsent(db, tenantId, id) === 'revoked') { + // Distinct reason string: "opted out" and "never opted in" are + // different facts, and the Outbox / inbox reason maps read them. + return { allowed: false, reason: 'sms opt-out' }; + } + } + if (purpose === 'notification' && requiresExpressSmsConsent(roleKind ?? 'client')) { + // No identifiable contact means nothing to check consent against, and a + // consumer fails closed. + if (!contactId) return { allowed: false, reason: 'no sms consent' }; + if (await latestConsent(db, tenantId, contactId) !== 'granted') { + return { allowed: false, reason: 'no sms consent' }; + } + } + + const gate = await managedSendAllowed(db, env ?? {}, tenantId, smsMode); + if (!gate.allowed) { + logger.info('[sms-gate] blocked by managed compliance gate', { tenantId, reason: gate.reason }); + return { allowed: false, reason: gate.reason ?? 'managed_not_approved' }; + } + + // 'own' is BYO and uncapped. THROWS on exhaustion (402) rather than + // returning — every caller already surfaces that as an error response, and + // a quota block is not the same kind of answer as "this recipient opted + // out". + if (quota && smsMode !== 'own') { + await quota.guard.checkMessagingQuota(tenantId, quota.tier, 'sms'); + } + + return { + allowed: true, + smsMode, + companyPhone: cfg?.companyPhone ?? null, + reviewUrl: cfg?.reviewUrl ?? null, + }; +} diff --git a/server/services/automation/send-one-sms.ts b/server/services/automation/send-one-sms.ts index d81c30a89..fc26b6ee0 100644 --- a/server/services/automation/send-one-sms.ts +++ b/server/services/automation/send-one-sms.ts @@ -6,13 +6,18 @@ * path. Building a second "just send" path that routes around the TCPA gate * would be a regulatory failure, not a bug (design §3.5). * - * WHAT MOVED HERE, UNCHANGED from deliverSms: - * - TCPA consent gate (kind-based, IA-109) + recipient_contact_id lookup + * The GATE CHAIN itself no longer lives here — it moved to + * `lib/sms/send-gate.ts`, because the two operator test-send paths carried + * their own copies of it and a copy only has the gates someone remembered to + * add. What is left here is what a copy could not have shared: the + * automation_logs row this send belongs to. + * + * WHAT THIS STILL OWNS: + * - resolving which contact and role KIND the log is addressed to (IA-109), + * which the gate then decides on * - review_url fail-closed (when the body template references it) - * - managedSendAllowed fail-closed for unapproved managed_* tenants * - per-tenant provider resolution (Twilio / Telnyx) - * - PlanQuotaGuard pre-flight (platform mode) - * - BYO source tagging (`sms_byo` vs `sms`) on successful meter + * - status writes, and BYO source tagging (`sms_byo` vs `sms`) on the meter * * WHAT STAYS WITH THE CALLER: * - inserting the `pending` automation_logs row (trigger() and the manual @@ -21,21 +26,19 @@ * manual uses the role profile's `smsTemplateId`; the core receives the * already-chosen body template string * - * Diff the body of `sendOneSms` against the pre-extraction `deliverSms` when - * reviewing — the consent / gate / meter lines must be byte-identical in - * intent. Never throws; every unhappy path updates the log to skipped/failed. + * Never throws; every unhappy path updates the log to skipped/failed. */ import type { DrizzleD1Database } from 'drizzle-orm/d1'; import { eq, and } from 'drizzle-orm'; import type { tenants } from '../../lib/db/schema'; -import { automationLogs, tenantConfigs, contactRoleProfiles } from '../../lib/db/schema'; +import { automationLogs, contactRoleProfiles } from '../../lib/db/schema'; import { PRIMARY_CLIENT_KEY } from '../../lib/people/default-role-profiles'; import { logger } from '../../lib/logger'; import { currentPeriodKey } from '../../lib/usage/period'; import { interpolate, type FlushInspection } from './shared'; import { buildBaseTemplateVars } from './template-vars'; -import { managedSendAllowed, type ManagedSendGateEnv } from '../../lib/sms/managed-send-gate'; -import { requiresExpressSmsConsent } from '../../lib/sms/consent-basis'; +import type { ManagedSendGateEnv } from '../../lib/sms/managed-send-gate'; +import { smsSendGate } from '../../lib/sms/send-gate'; import type { RoleKind } from '../../lib/people/role-kinds'; import type { PlanQuotaGuard } from '../../features/plan-quota/guard'; import type { UsageMetric } from '../../lib/usage/period'; @@ -54,8 +57,6 @@ export type SendOneSmsArgs = { // the core only touches a handful of tables by name. // eslint-disable-next-line @typescript-eslint/no-explicit-any db: DrizzleD1Database; - /** Raw D1 handle — SmsConsentService still takes the binding, not drizzle. */ - rawDb: D1Database; log: typeof automationLogs.$inferSelect; inspection: FlushInspection; tenant: typeof tenants.$inferSelect; @@ -108,7 +109,7 @@ async function resolveRecipientRoleKind( export async function sendOneSms(args: SendOneSmsArgs): Promise { const { - db, rawDb, log, inspection, tenant, bodyTemplate, sms, + db, log, inspection, tenant, bodyTemplate, sms, appName, appHost, env, quotaGuard, metering, } = args; @@ -116,82 +117,50 @@ export async function sendOneSms(args: SendOneSmsArgs): Promise { db.update(automationLogs).set({ status: 'skipped', error: reason }) .where(and(eq(automationLogs.id, log.id), eq(automationLogs.tenantId, inspection.tenantId))); - // Consent gate. Two DIFFERENT rules, and conflating them is what let a - // revoked agent keep receiving texts: - // - // - **Revocation binds everyone.** Honoring STOP does not depend on the - // basis the first message was sent under — it is the one CTIA rule that - // is universal, and both published documents warrant it (ToS: STOP is - // honored "for all outbound recipients"; privacy notice: business - // counterparties keep STOP available). The inbound webhook matches - // contacts by PHONE with no kind filter, so it records revocations for - // agents and other business counterparties too — they were simply never - // read, because this whole block used to sit inside the express branch. - // - **Express consent is required only of consumers** (client kind). - // Agents / other business counterparties / staff are implied - // (D5 + A3.2); absence of a granted row is not a reason to withhold. + // The gate chain lives in ONE place now (`lib/sms/send-gate.ts`) so the + // template test-send and the settings test-connection run the same one. + // Three copies is how the STOP-revocation check ended up in only this path. // - // Keyed on the PER-RECIPIENT role stamped on the log, not the rule's - // recipientKind. IA-109: gate on KIND, not one KEY. + // What stays here is what is specific to an automation_logs row: which + // contact it is addressed to, and which role kind that contact holds. const roleKind = await resolveRecipientRoleKind(db, inspection.tenantId, log.recipientRoleKey); - { - const { SmsConsentService } = await import('../sms-consent.service'); - const consentSvc = new SmsConsentService(rawDb); - // The contact this log is addressed to, stamped at enqueue. - // - // Legacy rows predate that column. `inspection.clientContactId` is the - // PRIMARY client — a correct fallback for a log explicitly keyed to the - // primary client, and for an older log with no role key at all (which - // could only ever have been the primary client). - // - // For any OTHER client-kind role it names the wrong person — those fail - // closed instead of consulting the primary client's consent. - const isPrimaryClientLog = - log.recipientRoleKey === PRIMARY_CLIENT_KEY || log.recipientRoleKey == null; - const contactId = log.recipientContactId - ?? (isPrimaryClientLog ? inspection.clientContactId : null); + // The contact this log is addressed to, stamped at enqueue. + // + // Legacy rows predate that column. `inspection.clientContactId` is the + // PRIMARY client — a correct fallback for a log explicitly keyed to the + // primary client, and for an older log with no role key at all (which + // could only ever have been the primary client). + // + // For any OTHER client-kind role it names the wrong person — those fall + // through to the number match, and then fail closed on express consent. + const isPrimaryClientLog = + log.recipientRoleKey === PRIMARY_CLIENT_KEY || log.recipientRoleKey == null; + const contactId = log.recipientContactId + ?? (isPrimaryClientLog ? inspection.clientContactId : null); - if (!contactId) { - // No identifiable contact: a consumer fails closed (nothing to check - // consent against). An implied-basis recipient has no ledger to - // consult either, so there is no revocation that could apply. - if (requiresExpressSmsConsent(roleKind)) return void (await skip('no sms consent')); - } else { - const latest = await consentSvc.getLatest(inspection.tenantId, contactId); - // Distinct reason string: "opted out" and "never opted in" are - // different facts, and the Outbox/inbox reason maps read them. - if (latest === 'revoked') return void (await skip('sms opt-out')); - if (requiresExpressSmsConsent(roleKind) && latest !== 'granted') { - return void (await skip('no sms consent')); - } - } - } + const gate = await smsSendGate({ + db, + tenantId: inspection.tenantId, + to: log.recipient, + purpose: 'notification', + contactId, + roleKind, + env, + ...(quotaGuard ? { quota: { guard: quotaGuard, tier: tenant.tier } } : {}), + }); + if (!gate.allowed) return void (await skip(gate.reason)); const resolved = await sms.resolveProvider(inspection.tenantId); if (!resolved) return void (await skip('sms not configured')); const { provider, from, messagingServiceSid } = resolved; - const cfg = await db.select({ - companyPhone: tenantConfigs.companyPhone, - reviewUrl: tenantConfigs.reviewUrl, - smsMode: tenantConfigs.smsMode, - }).from(tenantConfigs).where(eq(tenantConfigs.tenantId, inspection.tenantId)).get(); - - const gateEnv: ManagedSendGateEnv = env ?? {}; - const gate = await managedSendAllowed(db, gateEnv, inspection.tenantId, cfg?.smsMode ?? 'platform'); - if (!gate.allowed) return void (await skip(gate.reason ?? 'managed_not_approved')); - - if (quotaGuard && cfg?.smsMode !== 'own') { - await quotaGuard.checkMessagingQuota(inspection.tenantId, tenant.tier, 'sms'); - } - const vars: Record = { ...buildBaseTemplateVars(inspection, tenant, appName, appHost), - company_phone: cfg?.companyPhone ?? '', + company_phone: gate.companyPhone ?? '', }; if (bodyTemplate.includes('{{review_url}}')) { - if (!cfg?.reviewUrl) return void (await skip('review_url not configured')); - vars.review_url = cfg.reviewUrl; + if (!gate.reviewUrl) return void (await skip('review_url not configured')); + vars.review_url = gate.reviewUrl; } const body = interpolate(bodyTemplate, vars); @@ -207,7 +176,7 @@ export async function sendOneSms(args: SendOneSmsArgs): Promise { const { recordSentStatus } = await import('../../api/sms'); await recordSentStatus(db, inspection.tenantId, res.id, Date.now()); try { - await metering?.record(tenant.id, cfg?.smsMode === 'own' ? 'sms_byo' : 'sms', currentPeriodKey(new Date())); + await metering?.record(tenant.id, gate.smsMode === 'own' ? 'sms_byo' : 'sms', currentPeriodKey(new Date())); } catch { /* metering must never break delivery */ } } else { await db.update(automationLogs).set({ status: 'failed', error: res.error }) diff --git a/server/services/automation/sms.ts b/server/services/automation/sms.ts index 4065672d2..aa50bcc3f 100644 --- a/server/services/automation/sms.ts +++ b/server/services/automation/sms.ts @@ -63,7 +63,6 @@ export function AutomationSms>(Base: T await sendOneSms({ db, - rawDb: this.db, log, inspection, tenant, diff --git a/tests/unit/messaging/message-template-sms-test-send.spec.ts b/tests/unit/messaging/message-template-sms-test-send.spec.ts index 1b35c986c..cedb64307 100644 --- a/tests/unit/messaging/message-template-sms-test-send.spec.ts +++ b/tests/unit/messaging/message-template-sms-test-send.spec.ts @@ -126,6 +126,68 @@ describe('POST /api/message-templates/test-send (SMS) — managed-send gate (Fix }); }); +describe('POST /api/message-templates/test-send (SMS) — STOP revocation', () => { + /** + * Honoring STOP is the one CTIA rule that binds universally — it does not + * depend on the basis the first message was sent under, and it does not + * care that this particular send is a "test". The real send path has + * checked it since `603bd7b6`; this path did not, because it is a second + * copy of the chain and the check simply was not there to receive it. + * + * A test send has no contact, so the check matches the NUMBER — the same + * match the inbound STOP webhook makes when it records the revocation. + */ + async function seedRevoked(phone: string) { + await db.insert(schema.contacts).values({ + id: 'c-stop', tenantId: TENANT, type: 'client', name: 'Stopped', phone, createdAt: new Date(), + } as never); + await db.insert(schema.smsConsentLog).values({ + id: 'sc-1', tenantId: TENANT, contactId: 'c-stop', recipientType: 'client', + action: 'revoked', disclosureVersion: 1, capturedVia: 'admin', createdAt: new Date(), + } as never); + } + + it('number that texted STOP → blocked, provider never called', async () => { + await seedRevoked('+15559991234'); + const { sendMessage } = stubResolvedProvider(); + + const app = buildApp(db, SAAS_PROFILE); + const res = await sendReq(app, FAKE_ENV); + const body = await res.json() as { success: boolean; error?: string }; + + expect(body.success).toBe(false); + expect(body.error).toBe('sms opt-out'); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it('same number after START → sends again', async () => { + await seedRevoked('+15559991234'); + await db.insert(schema.smsConsentLog).values({ + id: 'sc-2', tenantId: TENANT, contactId: 'c-stop', recipientType: 'client', + action: 'granted', disclosureVersion: 1, capturedVia: 'admin', + createdAt: new Date(Date.now() + 1000), + } as never); + const { sendMessage } = stubResolvedProvider(); + + const app = buildApp(db, SAAS_PROFILE); + const res = await sendReq(app, FAKE_ENV); + + expect((await res.json() as { success: boolean }).success).toBe(true); + expect(sendMessage).toHaveBeenCalledTimes(1); + }); + + it('a DIFFERENT number is unaffected when some other contact texted STOP', async () => { + await seedRevoked('+15550001111'); + const { sendMessage } = stubResolvedProvider(); + + const app = buildApp(db, SAAS_PROFILE); + const res = await sendReq(app, FAKE_ENV); + + expect((await res.json() as { success: boolean }).success).toBe(true); + expect(sendMessage).toHaveBeenCalledTimes(1); + }); +}); + describe('POST /api/message-templates/test-send (SMS) — free-tier pre-flight + metering (Fix 3)', () => { it('free tenant at 50/50 lifetime sms (platform mode) → 402 QUOTA_EXHAUSTED, provider never called', async () => { await new MeteringService(db as unknown as D1Database).record(TENANT, 'sms', '2026-06', 50); diff --git a/tests/unit/messaging/sms-api.spec.ts b/tests/unit/messaging/sms-api.spec.ts index d4bcb3618..25c32adfa 100644 --- a/tests/unit/messaging/sms-api.spec.ts +++ b/tests/unit/messaging/sms-api.spec.ts @@ -1539,6 +1539,51 @@ describe('POST /sms/test — managed-send compliance gate (Task 8)', () => { // ─── Free-tier pre-flight + source tagging (Task 5) ────────────────────────── +describe('POST /sms/test — STOP revocation', () => { + /** + * The settings "test connection" send was the third copy of the gate chain, + * and copies only carry the gates someone remembered to add. The + * STOP-revocation check landed in the real send path alone. + * + * A test send has no contact, so the check matches the NUMBER — the same + * match the inbound STOP webhook makes when recording the revocation. + */ + function stubProvider() { + const sendMessage = vi.fn().mockResolvedValue({ ok: true, id: 'SM_stop' }); + vi.spyOn(resolveTwilioModule, 'loadProviderForTenant').mockResolvedValue({ + provider: { sendMessage, validateInboundSignature: vi.fn().mockResolvedValue(false) }, + from: '+15550009999', + }); + return sendMessage; + } + + afterEach(() => { vi.restoreAllMocks(); }); + + it('number that texted STOP → blocked, provider never called', async () => { + await db.insert(schema.contacts).values({ + id: 'c-stop', tenantId: TENANT, type: 'client', name: 'Stopped', + phone: '+15559991234', createdAt: new Date(), + } as never); + await db.insert(schema.smsConsentLog).values({ + id: 'sc-stop', tenantId: TENANT, contactId: 'c-stop', recipientType: 'client', + action: 'revoked', disclosureVersion: 1, capturedVia: 'admin', createdAt: new Date(), + } as never); + const sendMessage = stubProvider(); + + const app = buildApp(db, SAAS_PROFILE); + const res = await app.request('/api/admin/sms/test', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ to: '+15559991234' }), + }, FAKE_ENV, makeExecCtx()); + + const body = await res.json() as { success: boolean; error?: string }; + expect(body.success).toBe(false); + expect(body.error).toBe('sms opt-out'); + expect(sendMessage).not.toHaveBeenCalled(); + }); +}); + describe('POST /sms/test — free-tier pre-flight + source tagging (Task 5)', () => { /** * Stub the provider-aware loader itself rather than real Twilio creds + diff --git a/tests/unit/messaging/sms-send-gate.spec.ts b/tests/unit/messaging/sms-send-gate.spec.ts new file mode 100644 index 000000000..0c4fbf45f --- /dev/null +++ b/tests/unit/messaging/sms-send-gate.spec.ts @@ -0,0 +1,156 @@ +/** + * `smsSendGate` — the one chain every outbound SMS passes through. + * + * There were three copies of this chain, and the reason that mattered is + * recorded in the module: when STOP-revocation was added it landed in one of + * them. Nobody skipped a step; the other two were not there to receive it. + * + * So the tests that matter here are the ones about WHICH gates a purpose is + * exempt from — because "exempt from express consent" and "exempt from + * whatever nobody copied" look identical from the outside until something goes + * wrong. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createTestDb, setupSchema } from '../db'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import * as schema from '../../../server/lib/db/schema'; +import { smsSendGate } from '../../../server/lib/sms/send-gate'; + +const TENANT = 't-gate'; +const PHONE = '+15559991234'; + +let db: BetterSQLite3Database; +let sqlite: { close: () => void }; + +beforeEach(async () => { + const fx = createTestDb(); + db = fx.db as BetterSQLite3Database; + sqlite = fx.sqlite; + await setupSchema(fx.sqlite); + await db.insert(schema.tenants).values({ + id: TENANT, name: 'Acme', slug: TENANT, status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + } as never); +}); +afterEach(() => sqlite.close()); + +async function seedContact(id: string, phone: string | null) { + await db.insert(schema.contacts).values({ + id, tenantId: TENANT, type: 'client', name: id, phone, createdAt: new Date(), + } as never); +} +async function seedConsent(id: string, contactId: string, action: 'granted' | 'revoked', at = new Date()) { + await db.insert(schema.smsConsentLog).values({ + id, tenantId: TENANT, contactId, recipientType: 'client', + action, disclosureVersion: 1, capturedVia: 'admin', createdAt: at, + } as never); +} +const gate = (over: Partial[0]> = {}) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + smsSendGate({ db: db as any, tenantId: TENANT, to: PHONE, purpose: 'notification', ...over }); + +describe('smsSendGate — express consent (consumers only)', () => { + it('a consumer with no consent record is refused', async () => { + await seedContact('c1', PHONE); + const r = await gate({ contactId: 'c1', roleKind: 'client' }); + expect(r).toEqual({ allowed: false, reason: 'no sms consent' }); + }); + + it('a consumer with granted consent is allowed', async () => { + await seedContact('c1', PHONE); + await seedConsent('s1', 'c1', 'granted'); + expect((await gate({ contactId: 'c1', roleKind: 'client' })).allowed).toBe(true); + }); + + it('an agent with no consent record is allowed — implied basis, not silence', async () => { + await seedContact('a1', PHONE); + expect((await gate({ contactId: 'a1', roleKind: 'agent' })).allowed).toBe(true); + }); + + it('a consumer with no identifiable contact fails closed', async () => { + const r = await gate({ contactId: null, roleKind: 'client' }); + expect(r).toEqual({ allowed: false, reason: 'no sms consent' }); + }); +}); + +describe('smsSendGate — revocation binds everyone', () => { + it('refuses a consumer who revoked', async () => { + await seedContact('c1', PHONE); + await seedConsent('s1', 'c1', 'granted', new Date(1000)); + await seedConsent('s2', 'c1', 'revoked', new Date(2000)); + const r = await gate({ contactId: 'c1', roleKind: 'client' }); + // A distinct reason from "never opted in" — the Outbox reason maps read + // these, and "opted out" and "never opted in" are different facts. + expect(r).toEqual({ allowed: false, reason: 'sms opt-out' }); + }); + + it('refuses an AGENT who revoked, even though their basis is implied', async () => { + // The rule that is easy to get wrong: express consent is a consumer + // rule, revocation is not. Conflating them is what let a revoked agent + // keep receiving texts. + await seedContact('a1', PHONE); + await seedConsent('s1', 'a1', 'revoked'); + expect(await gate({ contactId: 'a1', roleKind: 'agent' })) + .toEqual({ allowed: false, reason: 'sms opt-out' }); + }); + + it('honours a later START', async () => { + await seedContact('c1', PHONE); + await seedConsent('s1', 'c1', 'revoked', new Date(1000)); + await seedConsent('s2', 'c1', 'granted', new Date(2000)); + expect((await gate({ contactId: 'c1', roleKind: 'client' })).allowed).toBe(true); + }); +}); + +describe('smsSendGate — purpose: test', () => { + it('is exempt from express consent, because there is no contact to hold any', async () => { + expect((await gate({ purpose: 'test' })).allowed).toBe(true); + }); + + it('is NOT exempt from revocation — a number that texted STOP is refused', async () => { + await seedContact('c1', PHONE); + await seedConsent('s1', 'c1', 'revoked'); + expect(await gate({ purpose: 'test' })) + .toEqual({ allowed: false, reason: 'sms opt-out' }); + }); + + it('matches the number the way the inbound webhook does, not by string equality', async () => { + // The webhook normalizes on read because stored phones may not be. If + // these two matchers disagreed, a revocation could be recorded against + // a contact this check would then fail to find — the revocation would + // exist and do nothing. + await seedContact('c1', '(555) 999-1234'); + await seedConsent('s1', 'c1', 'revoked'); + expect(await gate({ purpose: 'test', to: '+15559991234' })) + .toEqual({ allowed: false, reason: 'sms opt-out' }); + }); + + it('leaves an unrelated contact alone', async () => { + await seedContact('c1', '+15550001111'); + await seedConsent('s1', 'c1', 'revoked'); + expect((await gate({ purpose: 'test' })).allowed).toBe(true); + }); +}); + +describe('smsSendGate — managed compliance', () => { + it('refuses an unapproved managed tenant before anything is sent', async () => { + await db.insert(schema.tenantConfigs).values({ + tenantId: TENANT, smsMode: 'managed_dedicated', updatedAt: new Date(), + } as never); + const r = await gate({ purpose: 'test' }); + expect(r.allowed).toBe(false); + expect((r as { reason: string }).reason).toBe('managed_not_approved'); + }); + + it('reports the tenant sms mode and config back, so the caller needs no second read', async () => { + await db.insert(schema.tenantConfigs).values({ + tenantId: TENANT, smsMode: 'own', companyPhone: '+15551110000', + reviewUrl: 'https://g.example/review', updatedAt: new Date(), + } as never); + const r = await gate({ purpose: 'test' }); + expect(r).toMatchObject({ + allowed: true, smsMode: 'own', + companyPhone: '+15551110000', reviewUrl: 'https://g.example/review', + }); + }); +}); From 459f4444c7d2aaf66d495b910aafd6742072c94d Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 31 Jul 2026 11:55:56 +0800 Subject: [PATCH 05/48] feat(notifications): a gate, because auditing found what auditing could miss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lint:provider-helpers` already guards the TRANSPORT. Transport was never the problem — everything above it was, and both defects were found by hand-auditing call sites. The second audit found what the first structurally could not: it swept ROUTES, and the miss was inside a service. A third audit would miss the next one. So `lint:notification-dispatch`, four HARD rules with NO baseline: route-builds-html a route may not build notification HTML unclassified-send a send must name what it is sms-send-without-gate an SMS send must consult smsSendGate second-gate-chain managedSendAllowed is callable from one file Every rule was proved to fail before being trusted: a probe file containing one violation of each produced four findings on the right lines and exit 1, and adding a classId cleared only the second — which matters, because "never call sendEmail" would be a different and wrong rule. No baseline is the deliberate part. Every rule is at zero TODAY, and that is only true because P3 and P4 made it true. A baseline is how the next violation gets admitted as pre-existing. Two sends were classified to reach zero. The per-role report delivery now carries the same class as the two branches beside it — it differs only in WORDING, chosen by the recipient's role, and a different template is not a different thing to have a preference about. The admin test send gets `admin-test-send` in a new `diagnostic` category: it only ever reaches whoever pressed the button, so no recipient can hold a preference about it, but the boundary still has to be able to say what it is sending. `diagnostic` is what keeps it off the preferences screen. Two sends are allowlisted rather than classified, and the distinction is the point. The automation RULES layer sends tenant-authored templates, so there is no fixed class id, and what class those carry is a real open question V2 has to answer. A wrong class is worse than a stated absence, so the gate names them instead of pretending. report-delivery.ts is 3 lines over its ratchet (733 → 736) for the annotation and its reason; splitting a 736-line route file is a refactor this change does not justify. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq --- package.json | 5 +- scripts/check-notification-dispatch.mjs | 186 ++++++++++++++++++++++ scripts/file-size-baseline.json | 2 +- server/api/inspections/report-delivery.ts | 5 +- server/api/message-templates.ts | 8 +- server/lib/notifications/classes.ts | 15 +- tests/unit/notifications/classes.spec.ts | 3 + 7 files changed, 218 insertions(+), 6 deletions(-) create mode 100644 scripts/check-notification-dispatch.mjs diff --git a/package.json b/package.json index b540d92f8..e63d6329b 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "type-check": "npm run i18n:compile && react-router typegen && npm run type-check:app && npm run type-check:api", "type-check:app": "node --max-old-space-size=8192 ./node_modules/typescript/bin/tsc -p tsconfig.json --noEmit --incremental --tsBuildInfoFile ./.tsbuildinfo.app", "type-check:api": "node --max-old-space-size=8192 ./node_modules/typescript/bin/tsc -p tsconfig.api.json --noEmit --incremental --tsBuildInfoFile ./.tsbuildinfo.api", - "lint": "cross-env NODE_OPTIONS=--max-old-space-size=12288 eslint . --cache --cache-strategy content && npm run lint:ds && npm run lint:svg && npm run lint:erasure && npm run lint:migrefs && npm run lint:english && npm run lint:filesize && npm run lint:dup && npm run lint:tenant-scope && npm run lint:status-literals && npm run lint:capability-decl && npm run lint:provider-helpers && npm run lint:tests && npm run lint:deadcode && npm run lint:timestamps && npm run lint:tz && npm run lint:i18n && npm run lint:i18n-catalog && npm run lint:naming", + "lint": "cross-env NODE_OPTIONS=--max-old-space-size=12288 eslint . --cache --cache-strategy content && npm run lint:ds && npm run lint:svg && npm run lint:erasure && npm run lint:migrefs && npm run lint:english && npm run lint:filesize && npm run lint:dup && npm run lint:tenant-scope && npm run lint:status-literals && npm run lint:capability-decl && npm run lint:provider-helpers && npm run lint:notification-dispatch && npm run lint:tests && npm run lint:deadcode && npm run lint:timestamps && npm run lint:tz && npm run lint:i18n && npm run lint:i18n-catalog && npm run lint:naming", "lint:ds": "node scripts/check-ds-tokens.mjs", "lint:naming": "node scripts/check-naming.mjs", "lint:svg": "node scripts/check-svg-dimensions.mjs", @@ -51,6 +51,7 @@ "lint:status-literals": "node scripts/check-status-literals.mjs", "lint:capability-decl": "node scripts/check-capability-declarations.mjs", "lint:provider-helpers": "node scripts/check-provider-helpers.mjs", + "lint:notification-dispatch": "node scripts/check-notification-dispatch.mjs", "lint:tests": "node scripts/check-test-layout.mjs", "lint:deadcode": "node scripts/check-deadcode.mjs", "lint:timestamps": "node scripts/check-timestamps.mjs", @@ -90,7 +91,7 @@ "mcp:snapshot": "node scripts/snapshot-openapi.mjs", "lint:english": "node scripts/check-english-only.mjs", "lint:eslint": "cross-env NODE_OPTIONS=--max-old-space-size=12288 eslint . --cache --cache-strategy content", - "lint:gates-full": "npm run lint:ds && npm run lint:svg && npm run lint:erasure && npm run lint:migrefs && npm run lint:english && npm run lint:filesize && npm run lint:dup && npm run lint:tenant-scope && npm run lint:status-literals && npm run lint:capability-decl && npm run lint:provider-helpers && npm run lint:tests && npm run lint:deadcode && npm run lint:timestamps && npm run lint:tz && npm run lint:i18n && npm run lint:i18n-catalog && npm run lint:naming", + "lint:gates-full": "npm run lint:ds && npm run lint:svg && npm run lint:erasure && npm run lint:migrefs && npm run lint:english && npm run lint:filesize && npm run lint:dup && npm run lint:tenant-scope && npm run lint:status-literals && npm run lint:capability-decl && npm run lint:provider-helpers && npm run lint:notification-dispatch && npm run lint:tests && npm run lint:deadcode && npm run lint:timestamps && npm run lint:tz && npm run lint:i18n && npm run lint:i18n-catalog && npm run lint:naming", "i18n:compile:cached": "node scripts/i18n-compile-if-changed.mjs" }, "dependencies": { diff --git a/scripts/check-notification-dispatch.mjs b/scripts/check-notification-dispatch.mjs new file mode 100644 index 000000000..7ae71f0b0 --- /dev/null +++ b/scripts/check-notification-dispatch.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node +/** + * Notification dispatch gate (`lint:notification-dispatch`). + * + * `lint:provider-helpers` guards the TRANSPORT — that email goes through an + * EmailProvider and SMS through a MessagingProvider. Transport was never the + * problem. Everything above it was: + * + * - a route that builds its own HTML sends real mail that is not branded, + * not editable, not translatable, and — the part that matters here — + * arrives at the boundary with no name, so no preference can ever apply + * to it; + * - a second copy of the SMS gate chain does not BYPASS the gates, it just + * has whichever ones someone remembered to copy. That is not a theory: + * the STOP-revocation check landed in one of three copies, and the other + * two went on texting numbers that had opted out. + * + * Both were found by hand-auditing call sites, twice, and the second audit + * found what the first structurally could not (it swept routes; the miss was + * inside a service). A third audit would miss the next one. So the rules are + * here instead. + * + * Every rule is HARD — there is no baseline. Each one is currently at zero, + * and the point is to keep it there: a baseline would let the next one in and + * call it pre-existing. + * + * node scripts/check-notification-dispatch.mjs + * + * console.* is intentional — build script, not server code. + */ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +const ROOT = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'); +const SERVER = join(ROOT, 'server'); + +function walkFiles(dir, out = []) { + let entries; + try { entries = readdirSync(dir); } catch { return out; } + for (const entry of entries) { + const p = join(dir, entry); + const st = statSync(p); + if (st.isDirectory()) { + if (entry === 'node_modules' || entry === 'dist' || entry === 'build') continue; + walkFiles(p, out); + } else if (/\.tsx?$/.test(entry) && !/\.(test|spec)\.tsx?$/.test(entry)) { + out.push(p); + } + } + return out; +} + +const rel = (p) => relative(ROOT, p).replace(/\\/g, '/'); +const lineOf = (source, index) => source.slice(0, index).split('\n').length; + +/** Drop comments so prose describing a pattern does not trip the detector. */ +function stripComments(source) { + return source + .replace(/\/\*[\s\S]*?\*\//g, (m) => ' '.repeat(m.length)) + .replace(/(^|[^:])\/\/.*$/gm, (m, p1) => p1 + ' '.repeat(m.length - p1.length)); +} + +/** + * The argument text of a call starting at `openParenIdx`, paren-balanced. + * Good enough for "does this call name a class" — nested calls and object + * literals are included, which is what we want. + */ +function callArgs(source, openParenIdx) { + let depth = 0; + for (let i = openParenIdx; i < source.length; i++) { + const ch = source[i]; + if (ch === '(') depth++; + else if (ch === ')') { + depth--; + if (depth === 0) return source.slice(openParenIdx + 1, i); + } + } + return source.slice(openParenIdx + 1); +} + +const failures = []; +const add = (file, raw, index, rule, snippet) => + failures.push({ rule, file: rel(file), line: lineOf(raw, index), snippet }); + +// ── Rule 1: routes must not hand-build notification HTML ─────────────────── +// Before P3 three sends did this. Each shipped a hardcoded button colour that +// ignored the company's own, and none of them could be edited or translated. +// Routes now name a service method; the service renders the template. +const HTML_IN_ROUTE_RE = /<(?:div|p|a|table|td|tr|span|h1|h2)\b[^>]*(?:\s(?:style|href|class)=)/g; +const HTML_IN_ROUTE_ALLOW = [ + // Renders the signed agreement DOCUMENT (print / PDF surface), not an email. + // It is the artifact itself, so there is no template to route it through. + /^server\/api\/agreements-render\.ts$/, +]; + +// ── Rule 2: a send must say what it is ───────────────────────────────────── +// `sendEmail(to, subject, html)` carries an address and a string. A preference +// check placed at the boundary has nothing to match against: "an email to +// jane@x.com" cannot be compared to "Jane muted review requests". +const SEND_EMAIL_RE = /\.sendEmail\s*\(/g; +const SEND_EMAIL_ALLOW = [ + // The email service layer itself — `sendRendered` supplies the class from + // the RenderResult, and `sendEmail` is the boundary being annotated. + /^server\/services\/email\//, + /^server\/lib\/email\//, + // The tenant-configured automation RULES layer. Its class model is a real + // open question (a rule's template is tenant-authored, so there is no fixed + // class id) and V2 decides it. Inventing one here would prejudge that, and a + // wrong class is worse than a stated absence. + /^server\/services\/automation\/deliver-email\.ts$/, + /^server\/lib\/automation-core\/deliver\.ts$/, +]; + +// ── Rule 3: no SMS send without consulting the gate ──────────────────────── +const SEND_MESSAGE_RE = /\.sendMessage\s*\(/g; +const SEND_MESSAGE_ALLOW = [ + // The provider adapters ARE the transport — they are what the gate protects. + /^server\/lib\/messaging\//, + /^server\/lib\/sms\/send-sms\.ts$/, +]; + +// ── Rule 4: exactly one copy of the gate chain ───────────────────────────── +// Reaching for `managedSendAllowed` outside the shared gate is how a second +// chain starts: one gate looks like enough until the next one is added +// somewhere else. +const MANAGED_GATE_RE = /\bmanagedSendAllowed\s*\(/g; +const MANAGED_GATE_ALLOW = [ + /^server\/lib\/sms\/managed-send-gate\.ts$/, + /^server\/lib\/sms\/send-gate\.ts$/, +]; + +const allowed = (r, list) => list.some((re) => re.test(r)); + +for (const file of walkFiles(SERVER)) { + const r = rel(file); + const raw = readFileSync(file, 'utf8'); + const source = stripComments(raw); + + if (r.startsWith('server/api/') && !allowed(r, HTML_IN_ROUTE_ALLOW)) { + for (const m of source.matchAll(HTML_IN_ROUTE_RE)) { + add(file, raw, m.index, 'route-builds-html', m[0].slice(0, 60)); + } + } + + if (!allowed(r, SEND_EMAIL_ALLOW)) { + for (const m of source.matchAll(SEND_EMAIL_RE)) { + const args = callArgs(source, m.index + m[0].length - 1); + if (!/\bclassId\s*:/.test(args)) { + add(file, raw, m.index, 'unclassified-send', '.sendEmail( … ) with no classId'); + } + } + } + + if (!allowed(r, SEND_MESSAGE_ALLOW)) { + for (const m of source.matchAll(SEND_MESSAGE_RE)) { + if (!/\bsmsSendGate\b/.test(source)) { + add(file, raw, m.index, 'sms-send-without-gate', '.sendMessage( — file never calls smsSendGate'); + } + } + } + + if (!allowed(r, MANAGED_GATE_ALLOW)) { + for (const m of source.matchAll(MANAGED_GATE_RE)) { + add(file, raw, m.index, 'second-gate-chain', 'managedSendAllowed( outside the shared gate'); + } + } +} + +if (failures.length) { + console.error('\nNotification-dispatch gate FAILED (no baseline — fix the call site):\n'); + for (const f of failures) { + console.error(` ${f.file}:${f.line} [${f.rule}] ${f.snippet}`); + } + console.error(` + route-builds-html → add a registry template (server/lib/email-templates/catalog/) + and a method on the EmailService; the route passes facts, not markup + unclassified-send → prefer sendRendered(), which takes the class from the rendered + template; otherwise pass { classId } from server/lib/notifications/classes.ts + sms-send-without-gate → call smsSendGate({ …, purpose }) first — see server/lib/sms/send-gate.ts + second-gate-chain → do not rebuild the chain; smsSendGate owns it, and a copy only + carries the gates someone remembered to add to it +`); + process.exit(1); +} + +console.log('Notification-dispatch gate: OK (0 unclassified sends, 0 route-built HTML, 1 gate chain).'); diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 118d14fd6..2672e04a0 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -11,7 +11,7 @@ "app/components/portal/sections/ReportView.tsx": 806, "app/routes/settings-communication.tsx": 777, "server/services/inspection.service.ts": 755, - "server/api/inspections/report-delivery.ts": 733, + "server/api/inspections/report-delivery.ts": 736, "app/routes/settings-communication-templates.tsx": 731, "server/services/inspection/inspection-analytics.service.ts": 727, "app/routes/template-edit.tsx": 719, diff --git a/server/api/inspections/report-delivery.ts b/server/api/inspections/report-delivery.ts index 4d9b7e3c4..23abe6c4c 100644 --- a/server/api/inspections/report-delivery.ts +++ b/server/api/inspections/report-delivery.ts @@ -419,7 +419,10 @@ const reportDeliveryRoutes = createApiRouter() interpolate(roleTemplate.subject, vars), interpolate(roleTemplate.body, vars), pdf ? [{ filename: `${address.replace(/[^a-z0-9]+/gi, '-')}-report.pdf`, content: pdf }] : undefined, - { inspector: sigInspector }, + // Same class as the two branches below: only the WORDING + // differs, chosen by role. A different template is not a + // different thing to have a preference about. + { inspector: sigInspector, classId: pdf ? 'report-ready-pdf' : 'report-ready' }, ); } else if (pdf) { await c.var.services.email.sendInspectionReportPdf(recipientEmail, address, linkUrl, pdf, sigInspector, sigHost); diff --git a/server/api/message-templates.ts b/server/api/message-templates.ts index e57727e0f..3a7e57877 100644 --- a/server/api/message-templates.ts +++ b/server/api/message-templates.ts @@ -208,7 +208,13 @@ const messageTemplateRoutes = createApiRouter() ? (c.get('tenantTier') ?? await readTenantTier(c.env.DB, tenantId)) : undefined; const emailSvc = await buildTenantEmailService(c.env, tenantId, quotaGuard, tenantTier); - const { delivered } = await emailSvc.sendEmail([to], interpolate(subject ?? '', vars), interpolate(body, vars)); + // `diagnostic`: this only ever reaches whoever pressed the button, so + // there is no recipient who could hold a preference about it — but the + // boundary still has to be able to say what it is sending. + const { delivered } = await emailSvc.sendEmail( + [to], interpolate(subject ?? '', vars), interpolate(body, vars), + undefined, { classId: 'admin-test-send' }, + ); return delivered ? c.json({ success: true }, 200) : c.json({ success: false, error: 'Email is not configured.' }, 200); }); diff --git a/server/lib/notifications/classes.ts b/server/lib/notifications/classes.ts index 9984c1bf8..1c4032c7f 100644 --- a/server/lib/notifications/classes.ts +++ b/server/lib/notifications/classes.ts @@ -34,7 +34,14 @@ */ import type { AutomationChannel } from '../../services/automation/shared'; -export type NotificationCategory = 'transactional' | 'operational' | 'marketing'; +/** + * `diagnostic` is not a notification anyone receives by being someone — it + * only ever goes to whoever pressed the button. It is in this vocabulary + * because the send boundary must be able to say WHAT it is sending, and + * "nothing" is not an answer. The preferences screen filters it out: there is + * no preference to express about your own test. + */ +export type NotificationCategory = 'transactional' | 'operational' | 'marketing' | 'diagnostic'; export interface NotificationClass { /** Stable id. For registry-backed email this IS the template trigger. */ @@ -83,6 +90,12 @@ export const NOTIFICATION_CLASSES: NotificationClass[] = [ { id: 'usage-quota-warning', label: 'Free inspections running out', category: 'operational', required: true, channels: ['email'] }, { id: 'usage-quota-reached', label: 'Free inspections used up', category: 'operational', required: true, channels: ['email'] }, + // ─── not a notification to anyone but the sender + // An admin sending their own message template to their own address to see + // what it looks like. Classified so the boundary is never handed a send it + // cannot name; `diagnostic` keeps it off the recipient's screen. + { id: 'admin-test-send', label: 'Test send (admin)', category: 'diagnostic', required: true, channels: ['email', 'sms'] }, + // ─── your inspection (spec §2.2) — the recipient may switch these off { id: 'booking-confirmation', label: 'Booking confirmation', category: 'transactional', required: false, channels: ['email', 'sms'] }, { id: 'message-notification', label: 'New message from your inspector', category: 'transactional', required: false, channels: ['email', 'in_app'] }, diff --git a/tests/unit/notifications/classes.spec.ts b/tests/unit/notifications/classes.spec.ts index 852b3fd0f..34daf628f 100644 --- a/tests/unit/notifications/classes.spec.ts +++ b/tests/unit/notifications/classes.spec.ts @@ -38,6 +38,9 @@ const NEVER_OFF = [ // A one-off share to a typed-in address: no account, no relationship, so no // preference can exist. See the third `required: true` case in classes.ts. 'repair-request-share', + // Only ever sent to whoever pressed the button — see the `diagnostic` + // category. Nobody else can have a preference about it. + 'admin-test-send', ]; /** Spec §2.2-§2.4 — the recipient's call. */ From c6051ff7cefcdfb548bd148140ab7f2234b1d766 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 31 Jul 2026 12:02:11 +0800 Subject: [PATCH 06/48] perf(gates): cache a gate on what it read, so running it twice costs nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate ladder says: do not run a gate the next rung will run. That is a rule someone has to remember, and it drifted inside the session that wrote it — lint:filesize four times in one task, type-check:api six, and a `vitest --changed` that quietly became all 3975 tests because package.json was in the diff. The whole argument of the last four commits is that a coupling held together by prose drifts, and the fix is to make the wrong thing cost nothing rather than ask anyone to avoid it. That applies here too. KEYED ON WHAT THE GATE READ, NOT ON THE INDEX. `git diff --cached` is the obvious key and it is wrong: these gates scan the WORKING TREE, so an unstaged edit introducing a violation would hash identically and the gate would print "cached" over a real failure. A false green is worse than no cache. The key is the gate's own source plus (path, mtime, size) of every file it scans. Three properties, because a cache is exactly how a gate silently stops working, and all three were proved rather than assumed: - a hit PRINTS that it was a hit - a FAILING run is never cached — verified by failing twice in a row - any flag (--update) bypasses it; a side effect is not cacheable Also verified: touching a scanned file re-runs, and a violation introduced after a passing run fails rather than reporting cached. Wired into exactly two gates. lint:deadcode is the only one whose cost is real work — 8.0s → 0.2s. The conformance gates are ~0.6s each and almost all of that is node startup the cache cannot remove, so wiring them would save under 0.3s apiece while adding seventeen chances to under-specify an input set, which is the one way this produces a false green. Not worth it, deliberately. Wiring knip up immediately earned its keep: it caught two dead exports this branch introduced (NotificationCategory, SmsPurpose), both only reachable from the full-lint rung the inner loop never runs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq --- .gitignore | 1 + scripts/check-deadcode.mjs | 17 ++++ scripts/check-notification-dispatch.mjs | 10 +- scripts/lib/gate-stamp.mjs | 117 ++++++++++++++++++++++++ server/lib/notifications/classes.ts | 2 +- server/lib/sms/send-gate.ts | 2 +- 6 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 scripts/lib/gate-stamp.mjs diff --git a/.gitignore b/.gitignore index 481b1c378..2accbf4d9 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,4 @@ scripts/backfill-zod-descriptions.report.md # SDD scratch (briefs, reports, diffs, progress ledger) .superpowers/ +.gate-cache/ diff --git a/scripts/check-deadcode.mjs b/scripts/check-deadcode.mjs index 0dc0cdefc..f82820845 100644 --- a/scripts/check-deadcode.mjs +++ b/scripts/check-deadcode.mjs @@ -28,6 +28,7 @@ import { readFileSync, writeFileSync, existsSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { join } from "node:path"; +import { gateStamp, collectInputs } from "./lib/gate-stamp.mjs"; const ROOT = new URL("..", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"); const KNIP_BIN = join(ROOT, "node_modules", "knip", "bin", "knip.js"); @@ -102,6 +103,21 @@ function collectKeys(report) { // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- +// knip is the one gate here whose cost is real work rather than node startup +// (~8.7s against ~0.6s for the conformance gates). Its verdict is a pure +// function of the source tree, knip.json and the baseline, so a run whose +// inputs are unchanged can only reach the same verdict. `--update` bypasses +// the cache: that run has a side effect. +const stamp = gateStamp( + "deadcode", + new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"), + collectInputs( + ["server", "app", "workers", "packages", "scripts", "tests"].map((d) => join(ROOT, d)), + [join(ROOT, "knip.json"), join(ROOT, "package.json"), join(ROOT, "tsconfig.json"), BASELINE], + ), +); +if (stamp.hit) process.exit(0); + const report = runKnip(); const current = collectKeys(report); @@ -133,6 +149,7 @@ if (violations.length > 0) { process.exit(1); } +stamp.save(); console.log( `Dead-code gate: OK (${baseline.size} baselined, 0 new findings).`, ); diff --git a/scripts/check-notification-dispatch.mjs b/scripts/check-notification-dispatch.mjs index 7ae71f0b0..c31b3d888 100644 --- a/scripts/check-notification-dispatch.mjs +++ b/scripts/check-notification-dispatch.mjs @@ -30,6 +30,7 @@ */ import { readFileSync, readdirSync, statSync } from 'node:fs'; import { join, relative } from 'node:path'; +import { gateStamp } from './lib/gate-stamp.mjs'; const ROOT = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'); const SERVER = join(ROOT, 'server'); @@ -78,6 +79,12 @@ function callArgs(source, openParenIdx) { return source.slice(openParenIdx + 1); } +// Nothing here reads anything but `server/**`, so a run whose inputs are +// byte-identical to the last one can only reach the same verdict. +const SCANNED = walkFiles(SERVER); +const stamp = gateStamp('notification-dispatch', new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'), SCANNED); +if (stamp.hit) process.exit(0); + const failures = []; const add = (file, raw, index, rule, snippet) => failures.push({ rule, file: rel(file), line: lineOf(raw, index), snippet }); @@ -131,7 +138,7 @@ const MANAGED_GATE_ALLOW = [ const allowed = (r, list) => list.some((re) => re.test(r)); -for (const file of walkFiles(SERVER)) { +for (const file of SCANNED) { const r = rel(file); const raw = readFileSync(file, 'utf8'); const source = stripComments(raw); @@ -183,4 +190,5 @@ if (failures.length) { process.exit(1); } +stamp.save(); console.log('Notification-dispatch gate: OK (0 unclassified sends, 0 route-built HTML, 1 gate chain).'); diff --git a/scripts/lib/gate-stamp.mjs b/scripts/lib/gate-stamp.mjs new file mode 100644 index 000000000..5000f4878 --- /dev/null +++ b/scripts/lib/gate-stamp.mjs @@ -0,0 +1,117 @@ +/** + * Gate result cache — so running a gate twice costs nothing. + * + * The gate ladder (`.claude/skills/gate-ladder`) says: do not run a gate the + * next automatic rung will run. That is a rule people have to remember, and it + * drifted within the session that wrote it — `lint:filesize` got run four + * times in one task, and a full `type-check` was run by hand seconds before + * the hook ran its own. + * + * A rule you have to remember is the same shape as the bugs this repo keeps + * finding: a coupling maintained by prose. So the fix is the same shape too — + * make the wasteful thing cost nothing, instead of asking anyone to avoid it. + * + * KEYED ON WHAT THE GATE READS, NOT ON THE INDEX. The obvious key is + * `git diff --cached`, and it is wrong: these gates scan the WORKING TREE, so + * an unstaged edit that introduced a violation would hash identically and the + * gate would print "cached" over a real failure. That is a false green, which + * is worse than no cache at all. The key is (gate source) + (path, mtime, + * size) of every file the gate scans, so any change to an input invalidates it. + * + * mtime+size rather than content: the point is to avoid the work, and reading + * every file to decide whether to read every file saves nothing. `statSync` on + * ~500 files costs ~15ms against 1–5s for the gates themselves. + * + * THREE RULES, because a cache is exactly the mechanism by which a gate can + * silently stop working: + * 1. a cache hit PRINTS that it was a hit — never silent + * 2. any error computing the key runs the gate (fail toward running) + * 3. any CLI flag (--update, --fix, …) bypasses the cache entirely — those + * runs have side effects, and a side effect is not cacheable + * + * Disable with `GATE_CACHE=0` when in doubt. CI does not set it: CI runs each + * gate once on a fresh checkout, so every key misses anyway. + */ +import { createHash } from 'node:crypto'; +import { readFileSync, writeFileSync, mkdirSync, statSync, readdirSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +const ROOT = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'); +const CACHE_DIR = join(ROOT, '..', '.gate-cache'); + +/** + * @param {string} name gate id, used as the cache filename + * @param {string} gateScript absolute path to the gate's own source + * @param {string[]} inputs absolute paths of every file the gate reads + * @returns {{ hit: boolean, save: () => void, key: string | null }} + */ +export function gateStamp(name, gateScript, inputs) { + // Rule 3 — a run with flags may have side effects; never cache it. + const hasFlags = process.argv.slice(2).some((a) => a.startsWith('-')); + if (hasFlags || process.env.GATE_CACHE === '0') { + return { hit: false, save: () => {}, key: null }; + } + + let key; + try { + const h = createHash('sha256'); + h.update(readFileSync(gateScript, 'utf8')); + // Sorted so directory-listing order cannot change the key on its own. + for (const p of [...inputs].sort()) { + const st = statSync(p); + h.update(`${relative(ROOT, p)}|${st.mtimeMs}|${st.size}\n`); + } + key = h.digest('hex').slice(0, 16); + } catch { + // Rule 2 — fail toward running the gate. + return { hit: false, save: () => {}, key: null }; + } + + const file = join(CACHE_DIR, `${name}.json`); + let stored = null; + try { stored = JSON.parse(readFileSync(file, 'utf8')).key; } catch { /* no cache yet */ } + + if (stored === key) { + // Rule 1 — a hit is visible. Someone reading CI or a hook transcript + // must be able to tell "passed" from "did not run". + console.log(`${name}: cached (inputs unchanged, key ${key})`); + return { hit: true, save: () => {}, key }; + } + + return { + hit: false, + key, + save: () => { + try { + mkdirSync(CACHE_DIR, { recursive: true }); + writeFileSync(file, JSON.stringify({ key, at: new Date().toISOString() })); + } catch { /* a cache that cannot be written is not an error */ } + }, + }; +} + +/** + * Every source file under `roots`, plus `extra` — the input set for a gate that + * shells out to a tool (knip) and therefore cannot enumerate what it read. + * + * A missing root is skipped rather than thrown: an over-broad root list is + * harmless (extra invalidation), while a throw would silently disable the gate + * through the fail-open path. + */ +export function collectInputs(roots, extra = []) { + const out = [...extra]; + const walk = (dir) => { + let entries; + try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const e of entries) { + if (e.isDirectory()) { + if (e.name === 'node_modules' || e.name === 'dist' || e.name === 'build' || e.name[0] === '.') continue; + walk(join(dir, e.name)); + } else if (/\.(tsx?|jsx?|mjs|json)$/.test(e.name)) { + out.push(join(dir, e.name)); + } + } + }; + for (const r of roots) walk(r); + return out; +} diff --git a/server/lib/notifications/classes.ts b/server/lib/notifications/classes.ts index 1c4032c7f..c8cfe013a 100644 --- a/server/lib/notifications/classes.ts +++ b/server/lib/notifications/classes.ts @@ -41,7 +41,7 @@ import type { AutomationChannel } from '../../services/automation/shared'; * "nothing" is not an answer. The preferences screen filters it out: there is * no preference to express about your own test. */ -export type NotificationCategory = 'transactional' | 'operational' | 'marketing' | 'diagnostic'; +type NotificationCategory = 'transactional' | 'operational' | 'marketing' | 'diagnostic'; export interface NotificationClass { /** Stable id. For registry-backed email this IS the template trigger. */ diff --git a/server/lib/sms/send-gate.ts b/server/lib/sms/send-gate.ts index 9cac4ae65..8d0161907 100644 --- a/server/lib/sms/send-gate.ts +++ b/server/lib/sms/send-gate.ts @@ -44,7 +44,7 @@ import { logger } from '../logger'; * is a test — a tenant testing against a number that texted STOP should be * told so, not quietly sent to. */ -export type SmsPurpose = 'notification' | 'test'; +type SmsPurpose = 'notification' | 'test'; export type SmsGateOutcome = | { allowed: true; smsMode: string; companyPhone: string | null; reviewUrl: string | null } From 5bfaa7ed53e9f705c9f5117ea3ba876ba7a85952 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 31 Jul 2026 12:13:42 +0800 Subject: [PATCH 07/48] feat(notifications): the preferences table, shaped by the constraint that has to hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One recipient's answer to "send me this or don't", per class per channel. ONE SUBJECT COLUMN, NOT TWO. The obvious design is a nullable user_id and a nullable contact_id with a rule that exactly one is set. It does not work: SQLite treats NULLs as DISTINCT in a unique index, so (t1, NULL, 'c1', 'email') does not conflict with itself. The constraint meant to guarantee one answer per (who, what, how) would silently admit duplicates — and a duplicate here is two contradictory answers with no rule for which wins. subject_kind + subject_id are both NOT NULL, so the index actually holds and the two-columns-one-truth state cannot be written. A spec asserts the UNIQUE rejection rather than describing it. subject_kind is part of the key, not a label: users.id and contacts.id are independent id spaces that can collide. ABSENCE IS NOT "OFF". No row means the class default applies, which is "send". Only an explicit enabled=false suppresses, and only for a class isSuppressible() allows — which fails closed on ids it has never heard of. So a preference row can never silence something the recipient is told is always sent. Erasure deletes these with their subject, and the reason is not tidiness: a contact id is REUSED after an erasure, so a surviving row hands the next person at that id the erased subject's mute settings — invisibly, and in the direction that withholds mail nobody asked to withhold. Scoped to subject_kind='contact'; staff preferences are not a consumer data subject's. The manifest-coverage drift guard binds the new rule to the orchestrator step that realizes it. The erasure test was proved twice. The first red was my own bad fixture id (the #88 describe block seeds contact-88, not contact-subject), which proves nothing about the code, so the delete was neutered afterwards to confirm the test actually catches its absence. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq --- migrations/0018_charming_wild_pack.sql | 14 + migrations/meta/0018_snapshot.json | 10031 ++++++++++++++++ migrations/meta/_journal.json | 7 + server/lib/compliance/erasure-manifest.ts | 10 + server/lib/compliance/erasure-orchestrator.ts | 18 + server/lib/db/schema/index.ts | 3 + .../lib/db/schema/notification-preferences.ts | 53 + .../notifications/preferences-schema.spec.ts | 68 + .../unit/privacy/erasure-orchestrator.spec.ts | 25 + 9 files changed, 10229 insertions(+) create mode 100644 migrations/0018_charming_wild_pack.sql create mode 100644 migrations/meta/0018_snapshot.json create mode 100644 server/lib/db/schema/notification-preferences.ts create mode 100644 tests/unit/notifications/preferences-schema.spec.ts diff --git a/migrations/0018_charming_wild_pack.sql b/migrations/0018_charming_wild_pack.sql new file mode 100644 index 000000000..5ddb5536d --- /dev/null +++ b/migrations/0018_charming_wild_pack.sql @@ -0,0 +1,14 @@ +CREATE TABLE `notification_preferences` ( + `id` text PRIMARY KEY NOT NULL, + `tenant_id` text NOT NULL, + `subject_kind` text NOT NULL, + `subject_id` text NOT NULL, + `class_id` text NOT NULL, + `channel` text NOT NULL, + `enabled` integer NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_notification_prefs_unique` ON `notification_preferences` (`tenant_id`,`subject_kind`,`subject_id`,`class_id`,`channel`);--> statement-breakpoint +CREATE INDEX `idx_notification_prefs_subject` ON `notification_preferences` (`tenant_id`,`subject_kind`,`subject_id`); \ No newline at end of file diff --git a/migrations/meta/0018_snapshot.json b/migrations/meta/0018_snapshot.json new file mode 100644 index 000000000..d4b3358c9 --- /dev/null +++ b/migrations/meta/0018_snapshot.json @@ -0,0 +1,10031 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c34715cc-c710-43f0-9f1a-2cf47fd89451", + "prevId": "6b02a5ec-df10-44ac-99e7-a48e5c182c4d", + "tables": { + "agreement_requests": { + "name": "agreement_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agreement_id": { + "name": "agreement_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "signature_base64": { + "name": "signature_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_at": { + "name": "signed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "viewed_at": { + "name": "viewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_at": { + "name": "sent_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_signature_base64": { + "name": "inspector_signature_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_signed_at": { + "name": "inspector_signed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_user_id": { + "name": "inspector_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_snapshot": { + "name": "content_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completion_policy": { + "name": "completion_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'all'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purged_at": { + "name": "purged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agreement_requests_token_unique": { + "name": "agreement_requests_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "idx_agreement_requests_verify_token": { + "name": "idx_agreement_requests_verify_token", + "columns": [ + "verification_token" + ], + "isUnique": true + }, + "idx_agreement_requests_tenant": { + "name": "idx_agreement_requests_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_agreement_requests_inspection": { + "name": "idx_agreement_requests_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "idx_agreement_requests_token_hash": { + "name": "idx_agreement_requests_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "agreement_requests_tenant_id_tenants_id_fk": { + "name": "agreement_requests_tenant_id_tenants_id_fk", + "tableFrom": "agreement_requests", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agreement_requests_inspection_id_inspections_id_fk": { + "name": "agreement_requests_inspection_id_inspections_id_fk", + "tableFrom": "agreement_requests", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agreement_requests_agreement_id_agreements_id_fk": { + "name": "agreement_requests_agreement_id_agreements_id_fk", + "tableFrom": "agreement_requests", + "tableTo": "agreements", + "columnsFrom": [ + "agreement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agreement_requests_inspector_user_id_users_id_fk": { + "name": "agreement_requests_inspector_user_id_users_id_fk", + "tableFrom": "agreement_requests", + "tableTo": "users", + "columnsFrom": [ + "inspector_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agreement_signers": { + "name": "agreement_signers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'client'" + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_enc": { + "name": "token_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "signature_base64": { + "name": "signature_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_at": { + "name": "signed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "viewed_at": { + "name": "viewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "on_behalf_of": { + "name": "on_behalf_of", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "on_behalf_disclaimer": { + "name": "on_behalf_disclaimer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_reminded_at": { + "name": "last_reminded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_agreement_signers_tenant_request": { + "name": "idx_agreement_signers_tenant_request", + "columns": [ + "tenant_id", + "request_id" + ], + "isUnique": false + }, + "idx_agreement_signers_request_email": { + "name": "idx_agreement_signers_request_email", + "columns": [ + "request_id", + "email" + ], + "isUnique": true + }, + "idx_agreement_signers_token_hash": { + "name": "idx_agreement_signers_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agreements": { + "name": "agreements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_agreements_tenant": { + "name": "idx_agreements_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "agreements_tenant_id_tenants_id_fk": { + "name": "agreements_tenant_id_tenants_id_fk", + "tableFrom": "agreements", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automation_logs": { + "name": "automation_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_role_key": { + "name": "recipient_role_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'email'" + }, + "send_at": { + "name": "send_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_contact_id": { + "name": "recipient_contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notice_id": { + "name": "notice_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_logs_pending": { + "name": "idx_automation_logs_pending", + "columns": [ + "tenant_id", + "status", + "send_at" + ], + "isUnique": false + }, + "idx_automation_logs_insp": { + "name": "idx_automation_logs_insp", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "uq_automation_logs_event": { + "name": "uq_automation_logs_event", + "columns": [ + "automation_id", + "inspection_id", + "event_id", + "channel", + "recipient" + ], + "isUnique": true, + "where": "event_id IS NOT NULL" + } + }, + "foreignKeys": { + "automation_logs_tenant_id_tenants_id_fk": { + "name": "automation_logs_tenant_id_tenants_id_fk", + "tableFrom": "automation_logs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automations": { + "name": "automations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_kind": { + "name": "recipient_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_role_profile_id": { + "name": "recipient_role_profile_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delay_minutes": { + "name": "delay_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "subject_template": { + "name": "subject_template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body_template": { + "name": "body_template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_template_id": { + "name": "email_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "conditions": { + "name": "conditions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channels": { + "name": "channels", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"email\"]'" + }, + "sms_body": { + "name": "sms_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sms_template_id": { + "name": "sms_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "in_app_template_id": { + "name": "in_app_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automations_tenant": { + "name": "idx_automations_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automations_tenant_id_tenants_id_fk": { + "name": "automations_tenant_id_tenants_id_fk", + "tableFrom": "automations", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "availability": { + "name": "availability", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_availability_inspector": { + "name": "idx_availability_inspector", + "columns": [ + "inspector_id" + ], + "isUnique": false + }, + "idx_availability_window_unique": { + "name": "idx_availability_window_unique", + "columns": [ + "inspector_id", + "day_of_week", + "start_time" + ], + "isUnique": true + } + }, + "foreignKeys": { + "availability_tenant_id_tenants_id_fk": { + "name": "availability_tenant_id_tenants_id_fk", + "tableFrom": "availability", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "availability_inspector_id_users_id_fk": { + "name": "availability_inspector_id_users_id_fk", + "tableFrom": "availability", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "availability_overrides": { + "name": "availability_overrides", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_available": { + "name": "is_available", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "start_time": { + "name": "start_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transparency": { + "name": "transparency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_avail_overrides_insp": { + "name": "idx_avail_overrides_insp", + "columns": [ + "inspector_id" + ], + "isUnique": false + }, + "idx_avail_overrides_block_unique": { + "name": "idx_avail_overrides_block_unique", + "columns": [ + "inspector_id", + "date" + ], + "isUnique": true, + "where": "is_available = 0 AND source IS NULL" + }, + "uq_avail_overrides_external": { + "name": "uq_avail_overrides_external", + "columns": [ + "inspector_id", + "source", + "external_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "availability_overrides_tenant_id_tenants_id_fk": { + "name": "availability_overrides_tenant_id_tenants_id_fk", + "tableFrom": "availability_overrides", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "availability_overrides_inspector_id_users_id_fk": { + "name": "availability_overrides_inspector_id_users_id_fk", + "tableFrom": "availability_overrides", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "calendar_blocks": { + "name": "calendar_blocks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_all_day": { + "name": "is_all_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_calendar_blocks_tenant_user_date": { + "name": "idx_calendar_blocks_tenant_user_date", + "columns": [ + "tenant_id", + "user_id", + "date" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "calendar_connection_read_calendars": { + "name": "calendar_connection_read_calendars", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_calendar_id": { + "name": "external_calendar_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_role": { + "name": "access_role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_conn_read_cal": { + "name": "uq_conn_read_cal", + "columns": [ + "connection_id", + "external_calendar_id" + ], + "isUnique": true + }, + "idx_conn_read_cal_tenant": { + "name": "idx_conn_read_cal_tenant", + "columns": [ + "tenant_id", + "connection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "calendar_connections": { + "name": "calendar_connections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials_enc": { + "name": "credentials_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials_dek_enc": { + "name": "credentials_dek_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "calendar_id": { + "name": "calendar_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connected_at": { + "name": "connected_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_calendar_connections_user_provider": { + "name": "uq_calendar_connections_user_provider", + "columns": [ + "user_id", + "provider" + ], + "isUnique": true + }, + "idx_calendar_connections_tenant_user": { + "name": "idx_calendar_connections_tenant_user", + "columns": [ + "tenant_id", + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comment_usage": { + "name": "comment_usage", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_comment_usage_user_last_used": { + "name": "idx_comment_usage_user_last_used", + "columns": [ + "tenant_id", + "user_id", + "last_used_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "comment_usage_comment_id_comments_id_fk": { + "name": "comment_usage_comment_id_comments_id_fk", + "tableFrom": "comment_usage", + "tableTo": "comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "comment_usage_tenant_id_user_id_comment_id_pk": { + "columns": [ + "tenant_id", + "user_id", + "comment_id" + ], + "name": "comment_usage_tenant_id_user_id_comment_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rating_bucket": { + "name": "rating_bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section": { + "name": "section", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_ids": { + "name": "section_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_labels": { + "name": "item_labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger_code": { + "name": "trigger_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "search_keywords": { + "name": "search_keywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_label": { + "name": "item_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repair_summary": { + "name": "repair_summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimate_min_cents": { + "name": "estimate_min_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimate_max_cents": { + "name": "estimate_max_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recommended_contractor_type_id": { + "name": "recommended_contractor_type_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_comments_tenant": { + "name": "idx_comments_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_comments_rating_bucket": { + "name": "idx_comments_rating_bucket", + "columns": [ + "tenant_id", + "rating_bucket" + ], + "isUnique": false + }, + "idx_comments_library_id": { + "name": "idx_comments_library_id", + "columns": [ + "library_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "comments_tenant_id_tenants_id_fk": { + "name": "comments_tenant_id_tenants_id_fk", + "tableFrom": "comments", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commercial_subtypes": { + "name": "commercial_subtypes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "based_on": { + "name": "based_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_disabled": { + "name": "is_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_commercial_subtypes_tenant_name": { + "name": "idx_commercial_subtypes_tenant_name", + "columns": [ + "tenant_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "commercial_subtypes_tenant_id_tenants_id_fk": { + "name": "commercial_subtypes_tenant_id_tenants_id_fk", + "tableFrom": "commercial_subtypes", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "concierge_confirm_tokens": { + "name": "concierge_confirm_tokens", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_concierge_tokens_expiry": { + "name": "idx_concierge_tokens_expiry", + "columns": [ + "expires_at" + ], + "isUnique": false + }, + "idx_concierge_confirm_token_hash": { + "name": "idx_concierge_confirm_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "concierge_confirm_tokens_inspection_id_inspections_id_fk": { + "name": "concierge_confirm_tokens_inspection_id_inspections_id_fk", + "tableFrom": "concierge_confirm_tokens", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contact_role_profiles": { + "name": "contact_role_profiles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_template_id": { + "name": "email_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sms_template_id": { + "name": "sms_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capability_overrides": { + "name": "capability_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_crp_tenant": { + "name": "idx_crp_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_crp_tenant_key": { + "name": "uq_crp_tenant_key", + "columns": [ + "tenant_id", + "key" + ], + "isUnique": true, + "where": "is_active = 1" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contacts": { + "name": "contacts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'client'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agency": { + "name": "agency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_user_id": { + "name": "agent_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_linked_at": { + "name": "agent_linked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_revoked_at": { + "name": "agent_revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_contacts_type": { + "name": "idx_contacts_type", + "columns": [ + "tenant_id", + "type" + ], + "isUnique": false + }, + "idx_contacts_tenant": { + "name": "idx_contacts_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_contacts_tenant_email": { + "name": "uq_contacts_tenant_email", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": true, + "where": "email IS NOT NULL AND archived_at IS NULL" + }, + "uq_contacts_tenant_agent_user": { + "name": "uq_contacts_tenant_agent_user", + "columns": [ + "tenant_id", + "agent_user_id" + ], + "isUnique": true, + "where": "agent_user_id IS NOT NULL AND archived_at IS NULL" + }, + "idx_contacts_agent_user": { + "name": "idx_contacts_agent_user", + "columns": [ + "agent_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "contacts_tenant_id_tenants_id_fk": { + "name": "contacts_tenant_id_tenants_id_fk", + "tableFrom": "contacts", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contractor_types": { + "name": "contractor_types", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_contractor_types_tenant": { + "name": "idx_contractor_types_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cost_items": { + "name": "cost_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "building_id": { + "name": "building_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_index": { + "name": "instance_index", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit_id": { + "name": "unit_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finding_key": { + "name": "finding_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "system": { + "name": "system", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "component": { + "name": "component", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cost_method": { + "name": "cost_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uom": { + "name": "uom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit_cost_cents": { + "name": "unit_cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lump_sum_cents": { + "name": "lump_sum_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "eul": { + "name": "eul", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "eff_age": { + "name": "eff_age", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rul": { + "name": "rul", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suggested_remedy": { + "name": "suggested_remedy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_ref": { + "name": "section_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "photo_ref": { + "name": "photo_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_cost_items_tenant_inspection": { + "name": "idx_cost_items_tenant_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "idx_cost_items_finding_key": { + "name": "idx_cost_items_finding_key", + "columns": [ + "finding_key" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "defect_categories": { + "name": "defect_categories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#6b7280'" + }, + "is_summary_driver": { + "name": "is_summary_driver", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_seed": { + "name": "is_seed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_defect_categories_tenant": { + "name": "idx_defect_categories_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "discount_codes": { + "name": "discount_codes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uses_count": { + "name": "uses_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_discount_codes_tenant": { + "name": "idx_discount_codes_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_discount_codes_code_tenant": { + "name": "uq_discount_codes_code_tenant", + "columns": [ + "upper(code)", + "tenant_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "discount_codes_tenant_id_tenants_id_fk": { + "name": "discount_codes_tenant_id_tenants_id_fk", + "tableFrom": "discount_codes", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_review_items": { + "name": "document_review_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_key": { + "name": "document_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_requested": { + "name": "is_requested", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_received": { + "name": "is_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_reviewed": { + "name": "is_reviewed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_na": { + "name": "is_na", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_doc_review_inspection": { + "name": "idx_doc_review_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "uq_doc_review_item": { + "name": "uq_doc_review_item", + "columns": [ + "inspection_id", + "document_key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_suppressions": { + "name": "email_suppressions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_provider": { + "name": "source_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_event_id": { + "name": "provider_event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_email_suppressions_email": { + "name": "idx_email_suppressions_email", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "erasure_log": { + "name": "erasure_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_email": { + "name": "subject_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identity_basis": { + "name": "identity_basis", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decisions_json": { + "name": "decisions_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retained_count": { + "name": "retained_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "anonymized_count": { + "name": "anonymized_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "deleted_count": { + "name": "deleted_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_note": { + "name": "response_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_erasure_log_tenant": { + "name": "idx_erasure_log_tenant", + "columns": [ + "tenant_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "esign_audit_logs": { + "name": "esign_audit_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prev_hash": { + "name": "prev_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_fingerprint": { + "name": "key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_esign_audit_logs_request": { + "name": "idx_esign_audit_logs_request", + "columns": [ + "tenant_id", + "request_id", + "created_at" + ], + "isUnique": false + }, + "idx_esign_audit_logs_event_dedup": { + "name": "idx_esign_audit_logs_event_dedup", + "columns": [ + "tenant_id", + "request_id", + "event" + ], + "isUnique": true, + "where": "event NOT LIKE 'signer.%'" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_types": { + "name": "event_types", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_duration_min": { + "name": "default_duration_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "default_price_cents": { + "name": "default_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#6366f1'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_event_types_tenant_slug": { + "name": "uq_event_types_tenant_slug", + "columns": [ + "tenant_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_types_tenant_id_tenants_id_fk": { + "name": "event_types_tenant_id_tenants_id_fk", + "tableFrom": "event_types", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_access_tokens": { + "name": "inspection_access_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'client'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_enc": { + "name": "token_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_iat_token": { + "name": "idx_iat_token", + "columns": [ + "token" + ], + "isUnique": true + }, + "idx_iat_inspection": { + "name": "idx_iat_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "idx_iat_recipient": { + "name": "idx_iat_recipient", + "columns": [ + "inspection_id", + "recipient_email" + ], + "isUnique": true + }, + "idx_iat_token_hash": { + "name": "idx_iat_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "inspection_access_tokens_tenant_id_tenants_id_fk": { + "name": "inspection_access_tokens_tenant_id_tenants_id_fk", + "tableFrom": "inspection_access_tokens", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_events": { + "name": "inspection_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type_id": { + "name": "event_type_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_min": { + "name": "duration_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'scheduled'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "results_received_at": { + "name": "results_received_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gcal_event_id": { + "name": "gcal_event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_events_scheduled": { + "name": "idx_inspection_events_scheduled", + "columns": [ + "tenant_id", + "scheduled_at" + ], + "isUnique": false + }, + "idx_inspection_events_inspection": { + "name": "idx_inspection_events_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspection_events_tenant_id_tenants_id_fk": { + "name": "inspection_events_tenant_id_tenants_id_fk", + "tableFrom": "inspection_events", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_events_inspection_id_inspections_id_fk": { + "name": "inspection_events_inspection_id_inspections_id_fk", + "tableFrom": "inspection_events", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inspection_events_event_type_id_event_types_id_fk": { + "name": "inspection_events_event_type_id_event_types_id_fk", + "tableFrom": "inspection_events", + "tableTo": "event_types", + "columnsFrom": [ + "event_type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_events_inspector_id_users_id_fk": { + "name": "inspection_events_inspector_id_users_id_fk", + "tableFrom": "inspection_events", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_inspectors": { + "name": "inspection_inspectors", + "columns": { + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'lead'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_insp_inspectors_tenant_user": { + "name": "idx_insp_inspectors_tenant_user", + "columns": [ + "tenant_id", + "user_id" + ], + "isUnique": false + }, + "idx_insp_inspectors_user": { + "name": "idx_insp_inspectors_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "inspection_inspectors_inspection_id_user_id_pk": { + "columns": [ + "inspection_id", + "user_id" + ], + "name": "inspection_inspectors_inspection_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_item_tag_links": { + "name": "inspection_item_tag_links", + "columns": { + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_tag_links_tenant": { + "name": "idx_tag_links_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_tag_links_tag": { + "name": "idx_tag_links_tag", + "columns": [ + "tag_id" + ], + "isUnique": false + }, + "idx_tag_links_inspection_item": { + "name": "idx_tag_links_inspection_item", + "columns": [ + "inspection_id", + "item_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "inspection_item_tag_links_inspection_id_item_id_tag_id_pk": { + "columns": [ + "inspection_id", + "item_id", + "tag_id" + ], + "name": "inspection_item_tag_links_inspection_id_item_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_media_pool": { + "name": "inspection_media_pool", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exif_data": { + "name": "exif_data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "annotations": { + "name": "annotations", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'photo'" + }, + "stream_uid": { + "name": "stream_uid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "poster_pct": { + "name": "poster_pct", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_sec": { + "name": "duration_sec", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'stream'" + }, + "poster_key": { + "name": "poster_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_media_pool_tenant": { + "name": "idx_media_pool_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_media_pool_inspection": { + "name": "idx_media_pool_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_messages": { + "name": "inspection_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "from_role": { + "name": "from_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachments": { + "name": "attachments", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_user_id": { + "name": "from_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_msg_inspection": { + "name": "idx_msg_inspection", + "columns": [ + "inspection_id", + "created_at" + ], + "isUnique": false + }, + "idx_msg_contact": { + "name": "idx_msg_contact", + "columns": [ + "tenant_id", + "contact_id", + "created_at" + ], + "isUnique": false + }, + "idx_msg_unread": { + "name": "idx_msg_unread", + "columns": [ + "tenant_id", + "contact_id", + "from_role" + ], + "isUnique": false, + "where": "\"inspection_messages\".\"read_at\" IS NULL" + } + }, + "foreignKeys": { + "inspection_messages_tenant_id_tenants_id_fk": { + "name": "inspection_messages_tenant_id_tenants_id_fk", + "tableFrom": "inspection_messages", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_messages_inspection_id_inspections_id_fk": { + "name": "inspection_messages_inspection_id_inspections_id_fk", + "tableFrom": "inspection_messages", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_people": { + "name": "inspection_people", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_profile_id": { + "name": "role_profile_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_ip_inspection": { + "name": "idx_ip_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "idx_ip_tenant": { + "name": "idx_ip_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_ip_insp_contact_role": { + "name": "uq_ip_insp_contact_role", + "columns": [ + "inspection_id", + "contact_id", + "role_profile_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_requests": { + "name": "inspection_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_phone": { + "name": "client_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_address": { + "name": "property_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "property_city": { + "name": "property_city", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_state": { + "name": "property_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_zip": { + "name": "property_zip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_amount_cents": { + "name": "total_amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unpaid'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_requests_tenant": { + "name": "idx_inspection_requests_tenant", + "columns": [ + "tenant_id", + "status", + "scheduled_at" + ], + "isUnique": false + }, + "idx_inspection_requests_email": { + "name": "idx_inspection_requests_email", + "columns": [ + "tenant_id", + "client_email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspection_requests_tenant_id_tenants_id_fk": { + "name": "inspection_requests_tenant_id_tenants_id_fk", + "tableFrom": "inspection_requests", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_results": { + "name": "inspection_results", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ydoc_state": { + "name": "ydoc_state", + "type": "blob", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating_system_id": { + "name": "rating_system_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rating_system_snapshot": { + "name": "rating_system_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_results_tenant": { + "name": "idx_results_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_results_inspection": { + "name": "idx_results_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "uq_results_inspection": { + "name": "uq_results_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "inspection_results_tenant_id_tenants_id_fk": { + "name": "inspection_results_tenant_id_tenants_id_fk", + "tableFrom": "inspection_results", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_results_inspection_id_inspections_id_fk": { + "name": "inspection_results_inspection_id_inspections_id_fk", + "tableFrom": "inspection_results", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_services": { + "name": "inspection_services", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_id": { + "name": "service_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_override_cents": { + "name": "price_override_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name_snapshot": { + "name": "name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_snapshot_cents": { + "name": "price_snapshot_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_insp_services_tenant": { + "name": "idx_insp_services_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_insp_services_insp": { + "name": "idx_insp_services_insp", + "columns": [ + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspection_services_tenant_id_tenants_id_fk": { + "name": "inspection_services_tenant_id_tenants_id_fk", + "tableFrom": "inspection_services", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_services_inspection_id_inspections_id_fk": { + "name": "inspection_services_inspection_id_inspections_id_fk", + "tableFrom": "inspection_services", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inspection_services_service_id_services_id_fk": { + "name": "inspection_services_service_id_services_id_fk", + "tableFrom": "inspection_services", + "tableTo": "services", + "columnsFrom": [ + "service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_types": { + "name": "inspection_types", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "based_on": { + "name": "based_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_types_tenant_name": { + "name": "idx_inspection_types_tenant_name", + "columns": [ + "tenant_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_units": { + "name": "inspection_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_unit_id": { + "name": "parent_unit_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unit'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "attrs": { + "name": "attrs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_units_tenant_inspection": { + "name": "idx_inspection_units_tenant_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "idx_inspection_units_parent": { + "name": "idx_inspection_units_parent", + "columns": [ + "parent_unit_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspections": { + "name": "inspections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_address": { + "name": "property_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address_place_id": { + "name": "address_place_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_street": { + "name": "address_street", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_city": { + "name": "address_city", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_state": { + "name": "address_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_zip": { + "name": "address_zip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_county": { + "name": "address_county", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_lat": { + "name": "address_lat", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_lng": { + "name": "address_lng", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_geocoded_at": { + "name": "address_geocoded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'requested'" + }, + "report_status": { + "name": "report_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'in_progress'" + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unpaid'" + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancel_notes": { + "name": "cancel_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_payment_required": { + "name": "is_payment_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_agreement_required": { + "name": "is_agreement_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_auto_sign_on_publish": { + "name": "is_auto_sign_on_publish", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "discount_code_id": { + "name": "discount_code_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "discount_amount_cents": { + "name": "discount_amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "closing_date": { + "name": "closing_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referral_source": { + "name": "referral_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_number": { + "name": "reference_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "internal_notes": { + "name": "internal_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "year_built": { + "name": "year_built", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sqft": { + "name": "sqft", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "foundation_type": { + "name": "foundation_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bedrooms": { + "name": "bedrooms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bathrooms": { + "name": "bathrooms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lot_size": { + "name": "lot_size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_facts": { + "name": "property_facts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_photo_id": { + "name": "cover_photo_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_crop": { + "name": "cover_crop", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_image_key": { + "name": "cover_image_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commercial_subtype": { + "name": "commercial_subtype", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report_tier": { + "name": "report_tier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "county": { + "name": "county", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_automations_disabled": { + "name": "is_automations_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "template_snapshot": { + "name": "template_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_snapshot_version": { + "name": "template_snapshot_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "profile_override": { + "name": "profile_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "require_defect_fields_override": { + "name": "require_defect_fields_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "concierge_status": { + "name": "concierge_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_team_mode": { + "name": "is_team_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "lead_inspector_id": { + "name": "lead_inspector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "helper_inspector_ids": { + "name": "helper_inspector_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "data_version": { + "name": "data_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "source_inspection_id": { + "name": "source_inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "root_inspection_id": { + "name": "root_inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reinspection_round": { + "name": "reinspection_round", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit_inspection_mode": { + "name": "unit_inspection_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'tagged'" + }, + "location_options": { + "name": "location_options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sampling_declaration": { + "name": "sampling_declaration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pca_narrative": { + "name": "pca_narrative", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deviations": { + "name": "deviations", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report_photo_mode": { + "name": "report_photo_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_start_ms": { + "name": "scheduled_start_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_end_ms": { + "name": "scheduled_end_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_min": { + "name": "duration_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "badge_layout_override": { + "name": "badge_layout_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report_photo_columns": { + "name": "report_photo_columns", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referred_by_contact_id": { + "name": "referred_by_contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_inspections_tenant": { + "name": "idx_inspections_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_inspections_request": { + "name": "idx_inspections_request", + "columns": [ + "request_id" + ], + "isUnique": false + }, + "idx_inspections_inspector": { + "name": "idx_inspections_inspector", + "columns": [ + "inspector_id" + ], + "isUnique": false + }, + "idx_inspections_tenant_status": { + "name": "idx_inspections_tenant_status", + "columns": [ + "tenant_id", + "status" + ], + "isUnique": false + }, + "idx_inspections_tenant_date": { + "name": "idx_inspections_tenant_date", + "columns": [ + "tenant_id", + "date" + ], + "isUnique": false + }, + "idx_inspections_inspector_date": { + "name": "idx_inspections_inspector_date", + "columns": [ + "inspector_id", + "date" + ], + "isUnique": false + }, + "idx_inspections_root": { + "name": "idx_inspections_root", + "columns": [ + "root_inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspections_tenant_id_tenants_id_fk": { + "name": "inspections_tenant_id_tenants_id_fk", + "tableFrom": "inspections", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_inspector_id_users_id_fk": { + "name": "inspections_inspector_id_users_id_fk", + "tableFrom": "inspections", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_template_id_templates_id_fk": { + "name": "inspections_template_id_templates_id_fk", + "tableFrom": "inspections", + "tableTo": "templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_discount_code_id_discount_codes_id_fk": { + "name": "inspections_discount_code_id_discount_codes_id_fk", + "tableFrom": "inspections", + "tableTo": "discount_codes", + "columnsFrom": [ + "discount_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_request_id_inspection_requests_id_fk": { + "name": "inspections_request_id_inspection_requests_id_fk", + "tableFrom": "inspections", + "tableTo": "inspection_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspector_credentials": { + "name": "inspector_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "member_number": { + "name": "member_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_r2_key": { + "name": "image_r2_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspector_credentials_tenant": { + "name": "idx_inspector_credentials_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_inspector_credentials_user": { + "name": "idx_inspector_credentials_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invoices": { + "name": "invoices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "line_items": { + "name": "line_items", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_at": { + "name": "sent_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_at": { + "name": "paid_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method": { + "name": "payment_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "partial_paid_at": { + "name": "partial_paid_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voided_at": { + "name": "voided_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "qbo_sync_status": { + "name": "qbo_sync_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + } + }, + "indexes": { + "idx_invoices_tenant": { + "name": "idx_invoices_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_invoices_inspection": { + "name": "idx_invoices_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "idx_invoices_contact": { + "name": "idx_invoices_contact", + "columns": [ + "tenant_id", + "contact_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invoices_tenant_id_tenants_id_fk": { + "name": "invoices_tenant_id_tenants_id_fk", + "tableFrom": "invoices", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invoices_inspection_id_inspections_id_fk": { + "name": "invoices_inspection_id_inspections_id_fk", + "tableFrom": "invoices", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invoices_contact_id_contacts_id_fk": { + "name": "invoices_contact_id_contacts_id_fk", + "tableFrom": "invoices", + "tableTo": "contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "marketplace_libraries": { + "name": "marketplace_libraries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "changelog": { + "name": "changelog", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_count": { + "name": "download_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_featured": { + "name": "is_featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_marketplace_libraries_kind_featured": { + "name": "idx_marketplace_libraries_kind_featured", + "columns": [ + "kind", + "is_featured" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "marketplace_templates": { + "name": "marketplace_templates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "changelog": { + "name": "changelog", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_count": { + "name": "download_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_featured": { + "name": "is_featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "message_templates": { + "name": "message_templates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "variables": { + "name": "variables", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_seeded": { + "name": "is_seeded", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_message_templates_tenant_channel": { + "name": "idx_message_templates_tenant_channel", + "columns": [ + "tenant_id", + "channel" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messaging_compliance": { + "name": "messaging_compliance", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'own'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subaccount_sid": { + "name": "subaccount_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_profile_sid": { + "name": "customer_profile_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_profile_status": { + "name": "customer_profile_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "brand_sid": { + "name": "brand_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "brand_status": { + "name": "brand_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "campaign_sid": { + "name": "campaign_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "campaign_status": { + "name": "campaign_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tfv_sid": { + "name": "tfv_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tfv_status": { + "name": "tfv_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "messaging_resource_sid": { + "name": "messaging_resource_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_meta": { + "name": "provider_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provisioned_number": { + "name": "provisioned_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provisioned_number_sid": { + "name": "provisioned_number_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "has_sender_attached": { + "name": "has_sender_attached", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "compliance_status": { + "name": "compliance_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'not_started'" + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_preferences": { + "name": "notification_preferences", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_kind": { + "name": "subject_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "class_id": { + "name": "class_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_notification_prefs_unique": { + "name": "idx_notification_prefs_unique", + "columns": [ + "tenant_id", + "subject_kind", + "subject_id", + "class_id", + "channel" + ], + "isUnique": true + }, + "idx_notification_prefs_subject": { + "name": "idx_notification_prefs_subject", + "columns": [ + "tenant_id", + "subject_kind", + "subject_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "orphaned_media": { + "name": "orphaned_media", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_orphaned_media_key": { + "name": "idx_orphaned_media_key", + "columns": [ + "tenant_id", + "r2_key" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "processed_webhook_events": { + "name": "processed_webhook_events", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "psq_responses": { + "name": "psq_responses", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "responses": { + "name": "responses", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'sent'" + }, + "share_token": { + "name": "share_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_at": { + "name": "sent_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_psq_inspection": { + "name": "uq_psq_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": true + }, + "idx_psq_share_token": { + "name": "idx_psq_share_token", + "columns": [ + "share_token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "qbo_connections": { + "name": "qbo_connections", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "realm_id": { + "name": "realm_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_enc": { + "name": "refresh_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_sync_enabled": { + "name": "is_sync_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "default_item_id": { + "name": "default_item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "qbo_entity_map": { + "name": "qbo_entity_map", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_type": { + "name": "oi_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_id": { + "name": "oi_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qbo_type": { + "name": "qbo_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qbo_id": { + "name": "qbo_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qbo_sync_token": { + "name": "qbo_sync_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "synced_at": { + "name": "synced_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_qbo_entity_map_qbo": { + "name": "idx_qbo_entity_map_qbo", + "columns": [ + "tenant_id", + "qbo_type", + "qbo_id" + ], + "isUnique": true + }, + "idx_qbo_entity_map_oi": { + "name": "idx_qbo_entity_map_oi", + "columns": [ + "tenant_id", + "oi_type", + "oi_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "qbo_sync_errors": { + "name": "qbo_sync_errors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_type": { + "name": "oi_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_id": { + "name": "oi_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_msg": { + "name": "error_msg", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_resolved": { + "name": "is_resolved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rating_systems": { + "name": "rating_systems", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "levels": { + "name": "levels", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_seed": { + "name": "is_seed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_rating_systems_tenant_slug": { + "name": "idx_rating_systems_tenant_slug", + "columns": [ + "tenant_id", + "slug" + ], + "isUnique": true + }, + "idx_rating_systems_tenant": { + "name": "idx_rating_systems_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "rating_systems_tenant_id_tenants_id_fk": { + "name": "rating_systems_tenant_id_tenants_id_fk", + "tableFrom": "rating_systems", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repair_request_items": { + "name": "repair_request_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repair_request_id": { + "name": "repair_request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finding_key": { + "name": "finding_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_title": { + "name": "section_title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_label": { + "name": "item_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "comment_snapshot": { + "name": "comment_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requested_credit_cents": { + "name": "requested_credit_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "defect_title_snapshot": { + "name": "defect_title_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_snapshot": { + "name": "location_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_snapshot": { + "name": "category_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trade_snapshot": { + "name": "trade_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_repair_request_items_rr": { + "name": "idx_repair_request_items_rr", + "columns": [ + "repair_request_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repair_requests": { + "name": "repair_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_kind": { + "name": "created_by_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_ref": { + "name": "created_by_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "custom_intro": { + "name": "custom_intro", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "share_token": { + "name": "share_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_repair_requests_inspection": { + "name": "idx_repair_requests_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "idx_repair_requests_share_token": { + "name": "idx_repair_requests_share_token", + "columns": [ + "share_token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_exports": { + "name": "report_exports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_report_exports_inspection": { + "name": "idx_report_exports_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_pdfs": { + "name": "report_pdfs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rendered_at": { + "name": "rendered_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_version": { + "name": "source_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ready'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_report_pdfs_inspection_type": { + "name": "uq_report_pdfs_inspection_type", + "columns": [ + "inspection_id", + "type", + "version_number" + ], + "isUnique": true + }, + "idx_report_pdfs_tenant": { + "name": "idx_report_pdfs_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_report_pdfs_status": { + "name": "idx_report_pdfs_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_report_pdfs_content_hash": { + "name": "idx_report_pdfs_content_hash", + "columns": [ + "inspection_id", + "type", + "content_hash" + ], + "isUnique": false + } + }, + "foreignKeys": { + "report_pdfs_tenant_id_tenants_id_fk": { + "name": "report_pdfs_tenant_id_tenants_id_fk", + "tableFrom": "report_pdfs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_signoff": { + "name": "report_signoff", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "person_id": { + "name": "person_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "license": { + "name": "license", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "qualifications_ref": { + "name": "qualifications_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_at": { + "name": "signed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signature_ref": { + "name": "signature_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_dual_role": { + "name": "is_dual_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "idx_report_signoff_inspection": { + "name": "idx_report_signoff_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "uq_report_signoff_role": { + "name": "uq_report_signoff_role", + "columns": [ + "inspection_id", + "role" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_versions": { + "name": "report_versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_json": { + "name": "snapshot_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prev_hash": { + "name": "prev_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_fingerprint": { + "name": "key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_amendment": { + "name": "is_amendment", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "idx_report_versions_inspection": { + "name": "idx_report_versions_inspection", + "columns": [ + "inspection_id", + "version_number" + ], + "isUnique": false + }, + "uq_report_versions_inspection_version": { + "name": "uq_report_versions_inspection_version", + "columns": [ + "inspection_id", + "version_number" + ], + "isUnique": true + }, + "idx_report_versions_verify_token": { + "name": "idx_report_versions_verify_token", + "columns": [ + "verification_token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "service_inspectors": { + "name": "service_inspectors", + "columns": { + "service_id": { + "name": "service_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_service_inspectors_tenant": { + "name": "idx_service_inspectors_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "service_inspectors_service_id_user_id_pk": { + "columns": [ + "service_id", + "user_id" + ], + "name": "service_inspectors_service_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "services": { + "name": "services", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agreement_id": { + "name": "agreement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_services_tenant": { + "name": "idx_services_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "services_tenant_id_tenants_id_fk": { + "name": "services_tenant_id_tenants_id_fk", + "tableFrom": "services", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "services_template_id_templates_id_fk": { + "name": "services_template_id_templates_id_fk", + "tableFrom": "services", + "tableTo": "templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "services_agreement_id_agreements_id_fk": { + "name": "services_agreement_id_agreements_id_fk", + "tableFrom": "services", + "tableTo": "agreements", + "columnsFrom": [ + "agreement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "signing_keys": { + "name": "signing_keys", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key_enc": { + "name": "private_key_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key_iv": { + "name": "private_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Ed25519'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "signing_keys_tenant_id_tenants_id_fk": { + "name": "signing_keys_tenant_id_tenants_id_fk", + "tableFrom": "signing_keys", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sms_consent_log": { + "name": "sms_consent_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_type": { + "name": "recipient_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "disclosure_version": { + "name": "disclosure_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "captured_via": { + "name": "captured_via", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_sms_consent_contact": { + "name": "idx_sms_consent_contact", + "columns": [ + "tenant_id", + "contact_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sms_delivery_status": { + "name": "sms_delivery_status", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_sms_delivery_status_msg": { + "name": "idx_sms_delivery_status_msg", + "columns": [ + "tenant_id", + "provider_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sms_disclosure_versions": { + "name": "sms_disclosure_versions", + "columns": { + "version": { + "name": "version", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_seed": { + "name": "is_seed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_tags_tenant_name": { + "name": "idx_tags_tenant_name", + "columns": [ + "tenant_id", + "name" + ], + "isUnique": true + }, + "idx_tags_tenant": { + "name": "idx_tags_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "templates": { + "name": "templates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating_system_id": { + "name": "rating_system_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commercial_subtype": { + "name": "commercial_subtype", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_featured": { + "name": "is_featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_profile_id": { + "name": "default_profile_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_templates_tenant": { + "name": "idx_templates_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_templates_rating_system": { + "name": "idx_templates_rating_system", + "columns": [ + "rating_system_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "templates_tenant_id_tenants_id_fk": { + "name": "templates_tenant_id_tenants_id_fk", + "tableFrom": "templates", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_custom_holidays": { + "name": "tenant_custom_holidays", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_tenant_custom_holidays_tenant_date": { + "name": "uq_tenant_custom_holidays_tenant_date", + "columns": [ + "tenant_id", + "date" + ], + "isUnique": true + }, + "idx_tenant_custom_holidays_tenant_date": { + "name": "idx_tenant_custom_holidays_tenant_date", + "columns": [ + "tenant_id", + "date" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_library_imports": { + "name": "tenant_library_imports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_semver": { + "name": "imported_semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_at": { + "name": "imported_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "uq_tenant_library_import": { + "name": "uq_tenant_library_import", + "columns": [ + "tenant_id", + "library_id" + ], + "isUnique": true + }, + "idx_tenant_library_imports_tenant": { + "name": "idx_tenant_library_imports_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_marketplace_import_history": { + "name": "tenant_marketplace_import_history", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_version": { + "name": "source_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_version": { + "name": "target_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rows_affected": { + "name": "rows_affected", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_marketplace_history_tenant": { + "name": "idx_marketplace_history_tenant", + "columns": [ + "tenant_id", + "created_at" + ], + "isUnique": false + }, + "idx_marketplace_history_template": { + "name": "idx_marketplace_history_template", + "columns": [ + "template_id" + ], + "isUnique": false + }, + "idx_marketplace_history_library": { + "name": "idx_marketplace_history_library", + "columns": [ + "library_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_marketplace_imports": { + "name": "tenant_marketplace_imports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "marketplace_template_id": { + "name": "marketplace_template_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_semver": { + "name": "imported_semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_template_id": { + "name": "local_template_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_at": { + "name": "imported_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_mkt_imports_tmpl": { + "name": "idx_mkt_imports_tmpl", + "columns": [ + "marketplace_template_id" + ], + "isUnique": false + }, + "idx_mkt_imports_tenant": { + "name": "idx_mkt_imports_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk": { + "name": "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk", + "tableFrom": "tenant_marketplace_imports", + "tableTo": "marketplace_templates", + "columnsFrom": [ + "marketplace_template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tenant_marketplace_imports_local_template_id_templates_id_fk": { + "name": "tenant_marketplace_imports_local_template_id_templates_id_fk", + "tableFrom": "tenant_marketplace_imports", + "tableTo": "templates", + "columnsFrom": [ + "local_template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_counters": { + "name": "usage_counters", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period_key": { + "name": "period_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_usage_counters_tenant": { + "name": "idx_usage_counters_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_counters_tenant_id_metric_period_key_pk": { + "columns": [ + "tenant_id", + "metric", + "period_key" + ], + "name": "usage_counters_tenant_id_metric_period_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_templates": { + "name": "email_templates", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "blocks": { + "name": "blocks", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "email_templates_tenant_id_tenants_id_fk": { + "name": "email_templates_tenant_id_tenants_id_fk", + "tableFrom": "email_templates", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_templates_tenant_id_trigger_pk": { + "columns": [ + "tenant_id", + "trigger" + ], + "name": "email_templates_tenant_id_trigger_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_configs": { + "name": "tenant_configs", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "support_email": { + "name": "support_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_address": { + "name": "company_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_pdf_footer_shown": { + "name": "is_pdf_footer_shown", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_pdf_page_numbers_shown": { + "name": "is_pdf_page_numbers_shown", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_pdf_license_shown": { + "name": "is_pdf_license_shown", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sender_email": { + "name": "sender_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reply_to": { + "name": "reply_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_mode": { + "name": "email_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'platform'" + }, + "video_mode": { + "name": "video_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'r2'" + }, + "sms_mode": { + "name": "sms_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'platform'" + }, + "sender_display_name": { + "name": "sender_display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "point_of_contact": { + "name": "point_of_contact", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'company'" + }, + "billing_url": { + "name": "billing_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_url": { + "name": "review_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_phone": { + "name": "company_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "integration_config": { + "name": "integration_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secrets_enc": { + "name": "secrets_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dek_enc": { + "name": "dek_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ics_token": { + "name": "ics_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "widget_allowed_origins": { + "name": "widget_allowed_origins", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_profile_id": { + "name": "default_profile_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'signature'" + }, + "attention_thresholds": { + "name": "attention_thresholds", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"agreement_unsigned_h\":72,\"invoice_overdue_h\":72,\"report_unpublished_h\":72}'" + }, + "inspection_prefs": { + "name": "inspection_prefs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_estimates_shown": { + "name": "is_estimates_shown", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_repair_list_enabled": { + "name": "is_repair_list_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_customer_repair_export_enabled": { + "name": "is_customer_repair_export_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_unpaid_blocked": { + "name": "is_unpaid_blocked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_unsigned_agreement_blocked": { + "name": "is_unsigned_agreement_blocked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "custom_referral_sources": { + "name": "custom_referral_sources", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dashboard_column_prefs": { + "name": "dashboard_column_prefs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_concierge_review_required": { + "name": "is_concierge_review_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_inspector_choice_allowed": { + "name": "is_inspector_choice_allowed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_pdf_pipeline_enabled": { + "name": "is_pdf_pipeline_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_team_mode_default": { + "name": "is_team_mode_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_apprentice_review_required": { + "name": "is_apprentice_review_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_guest_invites_enabled": { + "name": "is_guest_invites_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "require_defect_fields": { + "name": "require_defect_fields", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "agreement_retention_years": { + "name": "agreement_retention_years", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 6 + }, + "reinspection_statuses": { + "name": "reinspection_statuses", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_collab_editing_enabled": { + "name": "is_collab_editing_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sms_byo_provider": { + "name": "sms_byo_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_byo_provider": { + "name": "email_byo_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'resend'" + }, + "is_managed_eligible": { + "name": "is_managed_eligible", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "managed_provider": { + "name": "managed_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'twilio'" + }, + "is_reserve_schedule_enabled": { + "name": "is_reserve_schedule_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "reserve_term_years": { + "name": "reserve_term_years", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 12 + }, + "inflation_rate_bps": { + "name": "inflation_rate_bps", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_timezone": { + "name": "default_timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'UTC'" + }, + "booking_slot_mode": { + "name": "booking_slot_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'fixed'" + }, + "booking_slot_interval_min": { + "name": "booking_slot_interval_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "holiday_region": { + "name": "holiday_region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "holiday_public_policy": { + "name": "holiday_public_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "holiday_internal_policy": { + "name": "holiday_internal_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'advisory'" + }, + "default_locale": { + "name": "default_locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en-US'" + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "is_archive_revoking_access": { + "name": "is_archive_revoking_access", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "legal_mode": { + "name": "legal_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'hosted'" + }, + "custom_privacy_url": { + "name": "custom_privacy_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_terms_url": { + "name": "custom_terms_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "privacy_body": { + "name": "privacy_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terms_body": { + "name": "terms_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_configs_tenant_id_tenants_id_fk": { + "name": "tenant_configs_tenant_id_tenants_id_fk", + "tableFrom": "tenant_configs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenants": { + "name": "tenants", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'free'" + }, + "stripe_connect_account_id": { + "name": "stripe_connect_account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "max_users": { + "name": "max_users", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 5 + }, + "deployment_mode": { + "name": "deployment_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'shared'" + }, + "applied_cmd_seq": { + "name": "applied_cmd_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "applied_cred_seq": { + "name": "applied_cred_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tenants_slug_unique": { + "name": "tenants_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_invites": { + "name": "tenant_invites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'inspector'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mentor_id": { + "name": "mentor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assigned_section_ids": { + "name": "assigned_section_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "permission_overrides": { + "name": "permission_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_invites_tenant": { + "name": "idx_invites_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_tenant_invites_pending_email": { + "name": "uq_tenant_invites_pending_email", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": true, + "where": "status = 'pending'" + } + }, + "foreignKeys": { + "tenant_invites_tenant_id_tenants_id_fk": { + "name": "tenant_invites_tenant_id_tenants_id_fk", + "tableFrom": "tenant_invites", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "license_number": { + "name": "license_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_signature_base64": { + "name": "default_signature_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_signature_enabled": { + "name": "is_signature_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manager'" + }, + "onboarding_state": { + "name": "onboarding_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_totp_enabled": { + "name": "is_totp_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "totp_recovery_codes": { + "name": "totp_recovery_codes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totp_verified_at": { + "name": "totp_verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_referral_notification_enabled": { + "name": "is_referral_notification_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_report_notification_enabled": { + "name": "is_report_notification_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_paid_notification_enabled": { + "name": "is_paid_notification_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mentor_id": { + "name": "mentor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assigned_section_ids": { + "name": "assigned_section_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terms_accepted": { + "name": "terms_accepted", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permission_overrides": { + "name": "permission_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + "deleted_at" + ], + "isUnique": false + }, + "uq_users_tenant_email": { + "name": "uq_users_tenant_email", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": true, + "where": "deleted_at IS NULL" + }, + "idx_users_tenant": { + "name": "idx_users_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_users_slug_per_tenant": { + "name": "idx_users_slug_per_tenant", + "columns": [ + "tenant_id", + "slug" + ], + "isUnique": true + }, + "idx_users_email": { + "name": "idx_users_email", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "users_tenant_id_tenants_id_fk": { + "name": "users_tenant_id_tenants_id_fk", + "tableFrom": "users", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_slug": { + "name": "inspector_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_audit_tenant_created": { + "name": "idx_audit_tenant_created", + "columns": [ + "tenant_id", + "created_at" + ], + "isUnique": false + }, + "idx_audit_entity": { + "name": "idx_audit_entity", + "columns": [ + "entity_type", + "entity_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_logs_tenant_id_tenants_id_fk": { + "name": "audit_logs_tenant_id_tenants_id_fk", + "tableFrom": "audit_logs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration_test_results": { + "name": "integration_test_results", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_ok": { + "name": "is_ok", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tested_by_user_id": { + "name": "tested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tested_at": { + "name": "tested_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_integration_test_tenant_target": { + "name": "idx_integration_test_tenant_target", + "columns": [ + "tenant_id", + "target", + "tested_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_notifications_tenant_user_created": { + "name": "idx_notifications_tenant_user_created", + "columns": [ + "tenant_id", + "user_id", + "created_at" + ], + "isUnique": false + }, + "idx_notifications_tenant_user_unread": { + "name": "idx_notifications_tenant_user_unread", + "columns": [ + "tenant_id", + "user_id", + "read_at" + ], + "isUnique": false + }, + "idx_notifications_tenant_contact_created": { + "name": "idx_notifications_tenant_contact_created", + "columns": [ + "tenant_id", + "contact_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notifications_tenant_id_tenants_id_fk": { + "name": "notifications_tenant_id_tenants_id_fk", + "tableFrom": "notifications", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "parked_cmd_events": { + "name": "parked_cmd_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "envelope": { + "name": "envelope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_parked_cmd_events_received_at": { + "name": "idx_parked_cmd_events_received_at", + "columns": [ + "received_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "processed_cmd_events": { + "name": "processed_cmd_events", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cmd_type": { + "name": "cmd_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "processed_at": { + "name": "processed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "slug_reservations": { + "name": "slug_reservations", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_outbox": { + "name": "sync_outbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_tried_at": { + "name": "last_tried_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_sync_outbox_status_created": { + "name": "idx_sync_outbox_status_created", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_destruction_records": { + "name": "tenant_destruction_records", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_slug": { + "name": "tenant_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rows_deleted": { + "name": "rows_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "r2_objects": { + "name": "r2_objects", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "r2_bytes": { + "name": "r2_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "kv_keys": { + "name": "kv_keys", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "destroyed_at": { + "name": "destroyed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_destruction_tenant": { + "name": "idx_destruction_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_destruction_destroyed_at": { + "name": "idx_destruction_destroyed_at", + "columns": [ + "destroyed_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "client_uploads": { + "name": "client_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_kind": { + "name": "uploaded_by_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_ref": { + "name": "uploaded_by_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_name": { + "name": "uploaded_by_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_client_uploads_inspection": { + "name": "idx_client_uploads_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "uq_discount_codes_code_tenant": { + "columns": { + "upper(code)": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index d5380da67..10ace5247 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1785421496479, "tag": "0017_strange_multiple_man", "breakpoints": true + }, + { + "idx": 18, + "version": "6", + "when": 1785470983411, + "tag": "0018_charming_wild_pack", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/lib/compliance/erasure-manifest.ts b/server/lib/compliance/erasure-manifest.ts index d9611ea65..6fa5631b3 100644 --- a/server/lib/compliance/erasure-manifest.ts +++ b/server/lib/compliance/erasure-manifest.ts @@ -89,6 +89,16 @@ export const ERASURE_MANIFEST: ErasureRule[] = [ // the subject's rows (via contacts.email), not a column to null. { table: 'inspection_people', column: 'contact_id', category: 'user.contact.email', action: 'delete' }, + // ── notification_preferences (orphan cleanup) ───────────────────────────── + // No PII of its own — an answer to "send me this or don't", keyed on the + // contact id. Ids are REUSED after an erasure, so a surviving row hands the + // next person at that id the erased subject's mute settings: silently, and + // in the direction that withholds mail nobody asked to withhold. Deleted + // BEFORE the contacts delete, via the same contact-id resolution. + // Staff rows (`subject_kind = 'user'`) are untouched — employees are not + // consumer data subjects (see ERASURE_OUT_OF_SCOPE below). + { table: 'notification_preferences', column: 'subject_id', category: 'user.contact.email', action: 'delete' }, + // ── invoices (#88) ──────────────────────────────────────────────────────── // The money record is the tenant's ledger (P-4 authority chain) and stays; // the denormalized client identity is nulled in place. Rows are located by diff --git a/server/lib/compliance/erasure-orchestrator.ts b/server/lib/compliance/erasure-orchestrator.ts index c54d4a8b5..3820e661e 100644 --- a/server/lib/compliance/erasure-orchestrator.ts +++ b/server/lib/compliance/erasure-orchestrator.ts @@ -40,6 +40,7 @@ import { and, eq, inArray, or } from 'drizzle-orm'; import type { DrizzleD1Database } from 'drizzle-orm/d1'; import { contacts, + notificationPreferences, inspectionPeople, agreementRequests, agreementSigners, @@ -278,6 +279,23 @@ export async function runErasure( .all(); const subjectContactIds = (subjectContactRows as Array<{ id: string }>).map((c) => c.id); + // A preference row is keyed on a contact id, and contact ids are reused. + // Leaving these behind gives the NEXT person at that id the erased + // subject's mute settings — invisibly, and in the direction that withholds + // mail. Scoped to `subject_kind = 'contact'`: a staff member's own + // preferences are not a consumer data subject's. + await step('notification_preferences', 'delete', {}, async () => { + if (subjectContactIds.length === 0) return 0; + const res = await db.delete(notificationPreferences) + .where(and( + eq(notificationPreferences.tenantId, tenantId), + eq(notificationPreferences.subjectKind, 'contact'), + inArray(notificationPreferences.subjectId, subjectContactIds), + )) + .run(); + return changeCount(res); + }); + // The money record is the tenant's ledger (P-4 authority chain) and stays; // only the denormalized client identity is nulled. await step('invoices', 'null', {}, async () => { diff --git a/server/lib/db/schema/index.ts b/server/lib/db/schema/index.ts index fb77c23fd..c6f8e636a 100644 --- a/server/lib/db/schema/index.ts +++ b/server/lib/db/schema/index.ts @@ -84,3 +84,6 @@ export { reportSignoff, psqResponses, documentReviewItems } from './pca-complian // Commercial PCA Phase W — async .docx export status row (R2 key + lifecycle). export { reportExports } from './report-export'; export type { ReportExport, NewReportExport } from './report-export'; +// Recipient notification preferences — one answer per (subject, class, channel). +export { notificationPreferences } from './notification-preferences'; +export type { NotificationPreference, NewNotificationPreference } from './notification-preferences'; diff --git a/server/lib/db/schema/notification-preferences.ts b/server/lib/db/schema/notification-preferences.ts new file mode 100644 index 000000000..d4c6e9586 --- /dev/null +++ b/server/lib/db/schema/notification-preferences.ts @@ -0,0 +1,53 @@ +import { sqliteTable, text, integer, uniqueIndex, index } from 'drizzle-orm/sqlite-core'; + +/** + * One recipient's answer to "send me this or don't", per notification class + * per channel. + * + * ONE SUBJECT COLUMN, NOT TWO. The obvious shape is a nullable `user_id` and a + * nullable `contact_id` with a rule that exactly one is set. SQLite treats + * NULLs as DISTINCT in a unique index, so `(t1, NULL, 'c1', 'email')` does not + * conflict with itself — the constraint meant to guarantee one row per + * (who, what, how) would silently permit duplicates, and a duplicate here means + * two contradictory answers with no rule for which wins. `subjectKind` + + * `subjectId` are both NOT NULL, so the index actually holds and the + * two-columns-one-truth state cannot be written. + * + * `subjectKind` distinguishes an ACCOUNT holder (staff, agent — rows in + * `users`) from a CONTACT (a client with no login — rows in `contacts`). They + * are different id spaces that can collide, so the kind is part of the key, not + * a hint. + * + * ABSENCE IS NOT "OFF". No row means the class's default applies, which is + * "send". Only an explicit `enabled = false` suppresses, and only for a class + * `isSuppressible()` allows — see `server/lib/notifications/classes.ts`, which + * fails closed on ids it has never heard of. A preference can therefore never + * silence a notification the recipient is told is always sent. + * + * Erasure: rows here are deleted with their subject. A contact id can be + * reused after an erasure, and inheriting the erased person's mute settings + * would be both wrong and invisible. + */ +export const notificationPreferences = sqliteTable('notification_preferences', { + id: text('id').primaryKey(), + tenantId: text('tenant_id').notNull(), + /** Which id space `subjectId` belongs to. */ + subjectKind: text('subject_kind', { enum: ['user', 'contact'] }).notNull(), + subjectId: text('subject_id').notNull(), + /** A `NOTIFICATION_CLASSES` id. Not a template trigger — those are a subset. */ + classId: text('class_id').notNull(), + channel: text('channel', { enum: ['email', 'sms', 'in_app'] }).notNull(), + enabled: integer('enabled', { mode: 'boolean' }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), +}, (t) => [ + // One answer per (who, what, how). Every column is NOT NULL, so this + // constraint is real rather than NULL-defeated. + uniqueIndex('idx_notification_prefs_unique') + .on(t.tenantId, t.subjectKind, t.subjectId, t.classId, t.channel), + // The send-boundary read: "what has this subject muted?" + index('idx_notification_prefs_subject').on(t.tenantId, t.subjectKind, t.subjectId), +]); + +export type NotificationPreference = typeof notificationPreferences.$inferSelect; +export type NewNotificationPreference = typeof notificationPreferences.$inferInsert; diff --git a/tests/unit/notifications/preferences-schema.spec.ts b/tests/unit/notifications/preferences-schema.spec.ts new file mode 100644 index 000000000..84d0f7c0c --- /dev/null +++ b/tests/unit/notifications/preferences-schema.spec.ts @@ -0,0 +1,68 @@ +/** + * The constraint that decided the table's shape. + * + * The obvious design is a nullable `user_id` and a nullable `contact_id` with a + * rule that exactly one is set. It does not work: SQLite treats NULLs as + * DISTINCT in a unique index, so a row with `user_id = NULL` never conflicts + * with another row with `user_id = NULL`. The constraint meant to guarantee one + * answer per (who, what, how) would silently admit duplicates — and a duplicate + * here is two contradictory answers with no rule for which one wins. + * + * `subject_kind` + `subject_id`, both NOT NULL, make the index hold. These + * tests are what says so. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createTestDb, setupSchema } from '../db'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import * as schema from '../../../server/lib/db/schema'; + +const TENANT = 't-prefs'; + +let db: BetterSQLite3Database; +let sqlite: { close: () => void }; + +beforeEach(async () => { + const fx = createTestDb(); + db = fx.db as BetterSQLite3Database; + sqlite = fx.sqlite; + await setupSchema(fx.sqlite); +}); +afterEach(() => sqlite.close()); + +const row = (over: Partial = {}) => ({ + id: 'np-1', tenantId: TENANT, subjectKind: 'contact' as const, subjectId: 'c1', + classId: 'booking-confirmation', channel: 'email' as const, enabled: false, + createdAt: new Date(), updatedAt: new Date(), ...over, +}); + +describe('notification_preferences — one answer per (subject, class, channel)', () => { + it('rejects a second answer for the same subject, class and channel', async () => { + await db.insert(schema.notificationPreferences).values(row()); + await expect( + db.insert(schema.notificationPreferences).values(row({ id: 'np-2', enabled: true })), + ).rejects.toThrow(/UNIQUE/i); + }); + + it('keeps the two id spaces apart — a user and a contact may share an id string', async () => { + // `subject_kind` is part of the key, not a label. `users.id` and + // `contacts.id` are independent id spaces and can collide. + await db.insert(schema.notificationPreferences).values(row()); + await db.insert(schema.notificationPreferences).values( + row({ id: 'np-2', subjectKind: 'user' }), + ); + const all = await db.select().from(schema.notificationPreferences).all(); + expect(all).toHaveLength(2); + }); + + it('allows the same class on a different channel', async () => { + await db.insert(schema.notificationPreferences).values(row()); + await db.insert(schema.notificationPreferences).values(row({ id: 'np-2', channel: 'sms' })); + expect(await db.select().from(schema.notificationPreferences).all()).toHaveLength(2); + }); + + it('scopes to the tenant — the same contact id in another tenant is another subject', async () => { + await db.insert(schema.notificationPreferences).values(row()); + await db.insert(schema.notificationPreferences).values(row({ id: 'np-2', tenantId: 't-other' })); + expect(await db.select().from(schema.notificationPreferences).all()).toHaveLength(2); + }); +}); diff --git a/tests/unit/privacy/erasure-orchestrator.spec.ts b/tests/unit/privacy/erasure-orchestrator.spec.ts index 29f78bd88..d3a883c8e 100644 --- a/tests/unit/privacy/erasure-orchestrator.spec.ts +++ b/tests/unit/privacy/erasure-orchestrator.spec.ts @@ -499,6 +499,31 @@ describe('runErasure — the residences the original manifest missed (#88)', () expect(other?.clientName).toBe('John Other'); }); + it('notification_preferences: the subject rows go, because a contact id can be reused', async () => { + // A preference row is keyed on the contact id, not on a person. Ids are + // reused after an erasure, so leaving these behind hands the NEXT person + // at that id the erased subject's mute settings — silently, and in the + // direction that suppresses mail they never asked to suppress. + await db.insert(schema.notificationPreferences).values([ + { id: 'np-subject', tenantId: TENANT_A, subjectKind: 'contact', subjectId: 'contact-88', + classId: 'booking-confirmation', channel: 'email', enabled: false, + createdAt: new Date(), updatedAt: new Date() }, + { id: 'np-other', tenantId: TENANT_A, subjectKind: 'contact', subjectId: 'contact-other', + classId: 'booking-confirmation', channel: 'email', enabled: false, + createdAt: new Date(), updatedAt: new Date() }, + // A STAFF preference at the same id string in the other id space — + // proof the subject kind is part of the key, not decoration. + { id: 'np-user', tenantId: TENANT_A, subjectKind: 'user', subjectId: 'contact-88', + classId: 'booking-confirmation', channel: 'email', enabled: false, + createdAt: new Date(), updatedAt: new Date() }, + ]); + + await run(); + + const left = await db.select().from(schema.notificationPreferences).all(); + expect(left.map((r) => r.id).sort()).toEqual(['np-other', 'np-user']); + }); + it('email_suppressions: the opt-out row is RETAINED — deleting it would resume sending to someone who objected', async () => { await db.insert(schema.emailSuppressions).values({ id: 'sup-subject', tenantId: TENANT_A, email: SUBJECT_EMAIL, reason: 'complaint', sourceProvider: 'resend', createdAt: new Date(), From cc4adc8c61d8a77c655a62cf802bade393ebd18e Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 31 Jul 2026 12:22:08 +0800 Subject: [PATCH 08/48] feat(notifications): preferences enforced where the send happens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The email boundary now drops a recipient who switched this notification class off. Two halves, split deliberately: a PORT decides whether a class may be withheld from an address, the BOUNDARY decides what to do about it. THE REQUIRED CHECK RUNS FIRST, before any lookup, and that is what makes the screen trustworthy. A class the recipient is told is always sent must stay unmutable even if a row says otherwise — a stale row, a class whose required flag changed, a hand-written INSERT. isSuppressible() also fails closed on ids it has never heard of, so a newly added notification is never withheld before someone has decided it may be. The screen's promise and the send path's behaviour cannot diverge. An address resolves to BOTH id spaces. An agent with an account who is also a contact on an inspection is one human, and asking them to switch the same thing off twice is the kind of half-working control that is worse than none. FAIL-OPEN, like the suppression gate beside it. A failed query must never be the reason someone did not hear from us: nobody reports mail that never arrived. An UNCLASSIFIED send never consults the port at all — a preference that cannot be named must not be applied by guesswork. Absence is not "off": no row means the class default, which is "send". Two proofs rather than one, because the interesting failure here is a gate wired to nothing — which looks exactly like a gate that passes, and is how check-ts-range.mjs spent months reporting "skipped". So there is a test that assembles the service production assembles and asserts a real row stops a real provider call, and it was verified to go red when the port is unwired from the constructor. One test bug worth recording: the boundary probe first recorded its own argument instead of what reached the provider, so it passed while the filtered list never shrank. An assertion has to sit on the far side of the thing under test. SMS is deliberately untouched — consent, not preference, is the authority there, and it already has one gate chain. The automation RULES layer is still unclassified (the gate allowlists it): its trigger enum is a fixed 19-value vocabulary and is the right class, but that is its own step. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq --- server/lib/email/build-email-service.ts | 10 +- server/lib/notifications/preference-port.ts | 86 +++++++ server/services/email/base.ts | 45 ++++ .../preference-enforcement.spec.ts | 209 ++++++++++++++++++ 4 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 server/lib/notifications/preference-port.ts create mode 100644 tests/unit/notifications/preference-enforcement.spec.ts diff --git a/server/lib/email/build-email-service.ts b/server/lib/email/build-email-service.ts index 64dc9850f..a0d77b8ac 100644 --- a/server/lib/email/build-email-service.ts +++ b/server/lib/email/build-email-service.ts @@ -9,6 +9,7 @@ import type { EmailIdentityConfig } from './sender-identity'; import type { TemplateOverride } from '../email-templates/types'; import { resolveEmailProvider, coerceEmailByoProvider, type EmailByoProvider } from './resolve-provider'; import { buildEmailSuppression } from './suppression'; +import { buildNotificationPreferences } from '../notifications/preference-port'; import { logger } from '../logger'; import { ResendProvider } from './providers/resend'; import { RecordingEmailProvider } from './providers/recording'; @@ -185,6 +186,13 @@ export function assembleTenantEmailService( const suppression = meterTenantId ? buildEmailSuppression(env.DB, meterTenantId) : undefined; + // The recipient's own kill switch, under the SAME guard: it needs a tenant + // to scope the subject lookup, and without one it could only ever match the + // wrong person. A send with no tenant context is therefore ungated, which + // is the safe direction — it goes out. + const preferences = meterTenantId + ? buildNotificationPreferences(env.DB, meterTenantId) + : undefined; // TEST-ONLY email sink (E2E). Capture every message to KV instead of // sending, so E2E can read back links it cannot see from the browser (the @@ -203,7 +211,7 @@ export function assembleTenantEmailService( ); } - return new EmailService(apiKeySentinel, fromAddress, appName, emailIdentity, renderer, meter, provider, suppression, quota); + return new EmailService(apiKeySentinel, fromAddress, appName, emailIdentity, renderer, meter, provider, suppression, quota, preferences); } /** diff --git a/server/lib/notifications/preference-port.ts b/server/lib/notifications/preference-port.ts new file mode 100644 index 000000000..ed783439f --- /dev/null +++ b/server/lib/notifications/preference-port.ts @@ -0,0 +1,86 @@ +import { drizzle } from 'drizzle-orm/d1'; +import { and, eq, inArray, or } from 'drizzle-orm'; +import { contacts, notificationPreferences, users } from '../db/schema'; +import { isSuppressible } from './classes'; + +/** + * The send-path preference port. `EmailService` asks it, per recipient, whether + * this notification class may be withheld from this address. + * + * Deliberately the same shape as `EmailSuppressionPort` (WH-3), because it sits + * at the same boundary and answers the same kind of question. The two are NOT + * the same thing and must not be merged: suppression is a DELIVERABILITY fact + * about an address (it bounced, it complained) that applies to everything; + * a preference is a CHOICE about one kind of message. + */ +export interface NotificationPreferencePort { + /** May `classId` be withheld from `email` on this channel? */ + isMuted(classId: string, email: string): Promise; +} + +/** + * Build the tenant-scoped preference port for the email channel. + * + * THE REQUIRED CHECK COMES FIRST, and it is the reason this is trustworthy. A + * class the recipient is told is always sent must be unmutable even if a row + * somehow says otherwise — a stale row, a class whose `required` flag changed, + * a hand-written INSERT. `isSuppressible` fails closed on ids it has never + * heard of, so an unclassified or newly-added notification is never withheld. + * The screen's promise and the send path's behaviour therefore cannot diverge. + * + * An address is resolved to BOTH id spaces (`users` and `contacts`) because one + * person can be both — an agent with an account who is also a contact on an + * inspection. A mute in either space counts: they are the same human, and + * asking them to switch something off twice would be the kind of half-working + * control that is worse than none. + * + * FAIL-OPEN, like the suppression gate beside it: a lookup error means the mail + * goes out. Silently dropping notifications because a query failed is the worse + * of the two failures by a wide margin — the recipient never learns the message + * existed. + */ +export function buildNotificationPreferences(db: D1Database, tenantId: string): NotificationPreferencePort { + return { + async isMuted(classId: string, email: string): Promise { + // A required class is never withheld — checked before any lookup, so + // no row can ever override it. + if (!isSuppressible(classId)) return false; + + const d = drizzle(db); + const normalized = email.trim().toLowerCase(); + + const [userRows, contactRows] = await Promise.all([ + d.select({ id: users.id }).from(users) + .where(and(eq(users.tenantId, tenantId), eq(users.email, normalized))).all(), + d.select({ id: contacts.id }).from(contacts) + .where(and(eq(contacts.tenantId, tenantId), eq(contacts.email, normalized))).all(), + ]); + const userIds = userRows.map((r) => r.id); + const contactIds = contactRows.map((r) => r.id); + if (userIds.length === 0 && contactIds.length === 0) return false; + + const subjectMatch = [ + userIds.length + ? and(eq(notificationPreferences.subjectKind, 'user'), inArray(notificationPreferences.subjectId, userIds)) + : undefined, + contactIds.length + ? and(eq(notificationPreferences.subjectKind, 'contact'), inArray(notificationPreferences.subjectId, contactIds)) + : undefined, + ].filter(Boolean); + + const row = await d.select({ enabled: notificationPreferences.enabled }) + .from(notificationPreferences) + .where(and( + eq(notificationPreferences.tenantId, tenantId), + eq(notificationPreferences.classId, classId), + eq(notificationPreferences.channel, 'email'), + subjectMatch.length === 1 ? subjectMatch[0] : or(...subjectMatch), + eq(notificationPreferences.enabled, false), + )) + .get(); + + // Absence is not "off": no row means the class default applies. + return !!row; + }, + }; +} diff --git a/server/services/email/base.ts b/server/services/email/base.ts index a343af5a0..c1efd853e 100644 --- a/server/services/email/base.ts +++ b/server/services/email/base.ts @@ -74,6 +74,20 @@ export class EmailBaseService { * `meter` is. */ protected quota?: { preflight: () => Promise }, + /** + * The recipient's own kill switch. When injected, `sendEmail` drops any + * recipient who switched this notification CLASS off — which is only + * possible for a class `isSuppressible()` allows, checked inside the + * port before any lookup, so a preference can never silence something + * the recipient is told is always sent. + * + * Absent (standalone, legacy callers) ⇒ no gate. Deliberately the same + * shape and the same wiring as `suppression`: same boundary, same kind + * of answer. They stay separate because they are different facts — + * suppression is about an ADDRESS being undeliverable, a preference is + * about one KIND of message being unwanted. + */ + protected preferences?: { isMuted(classId: string, email: string): Promise }, ) { this.provider = provider ?? new ResendProvider({ apiKey: this.apiKey }); } @@ -222,6 +236,37 @@ export class EmailBaseService { to = allowed; } + // The recipient's preference. Only reachable when the send NAMED what + // it is — an unclassified send cannot be matched against "Jane muted + // review requests", and applying a preference by guesswork would be + // worse than applying none. FAIL-OPEN per recipient, exactly like the + // suppression gate above: a failed query must never be the reason + // someone did not hear from us, because nobody reports mail that never + // arrived. + if (this.preferences && opts?.classId) { + const classId = opts.classId; + const checked = await Promise.all( + to.map(async (addr) => { + try { + return { addr, muted: await this.preferences!.isMuted(classId, addr) }; + } catch { + return { addr, muted: false }; + } + }), + ); + const wanted = checked.filter((r) => !r.muted).map((r) => r.addr); + if (wanted.length !== to.length) { + // NO email/PII in the log — count and class only. + logger.info('[email] recipient(s) muted this class — skipping', { + classId, mutedCount: to.length - wanted.length, + }); + } + // Same benign skip shape as the missing-key / all-suppressed paths: + // a value existing callers already treat as "not sent", never a throw. + if (wanted.length === 0) return { delivered: false }; + to = wanted; + } + const resolved = this.identity ? resolveSenderIdentity(this.identity, opts?.inspector) : {}; diff --git a/tests/unit/notifications/preference-enforcement.spec.ts b/tests/unit/notifications/preference-enforcement.spec.ts new file mode 100644 index 000000000..18fc59c2a --- /dev/null +++ b/tests/unit/notifications/preference-enforcement.spec.ts @@ -0,0 +1,209 @@ +/** + * Preferences, enforced where the send happens. + * + * Two halves, and the split matters: the PORT decides whether a class may be + * withheld from an address, and the BOUNDARY decides what to do about it. A + * screen that lets someone switch a notification off is a lie until both work, + * and the failure is invisible — nobody reports mail they did not receive, and + * nobody reports mail they DID receive after muting it either. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createTestDb, setupSchema, toRawD1 } from '../db'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import * as schema from '../../../server/lib/db/schema'; + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; + +// eslint-disable-next-line import/order +import { buildNotificationPreferences } from '../../../server/lib/notifications/preference-port'; +// eslint-disable-next-line import/order +import { EmailBaseService } from '../../../server/services/email/base'; +// eslint-disable-next-line import/order +import { assembleTenantEmailService, type EmailServiceEnv } from '../../../server/lib/email/build-email-service'; + +const TENANT = 't-pref'; +const ADDR = 'jo@example.com'; + +let db: BetterSQLite3Database; +let sqlite: { close: () => void }; +let rawDb: D1Database; + +beforeEach(async () => { + const fx = createTestDb(); + db = fx.db as BetterSQLite3Database; + sqlite = fx.sqlite; + await setupSchema(fx.sqlite); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockDrizzle as any).mockReturnValue(db); + rawDb = toRawD1(fx.sqlite); + await db.insert(schema.tenants).values({ + id: TENANT, name: 'Acme', slug: TENANT, status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + } as never); +}); +afterEach(() => sqlite.close()); + +async function seedContact(id = 'c1', email = ADDR) { + await db.insert(schema.contacts).values({ + id, tenantId: TENANT, type: 'client', name: 'Jo', email, createdAt: new Date(), + } as never); +} +async function mute(classId: string, subjectId = 'c1', subjectKind: 'contact' | 'user' = 'contact') { + await db.insert(schema.notificationPreferences).values({ + id: `np-${subjectKind}-${subjectId}-${classId}`, tenantId: TENANT, + subjectKind, subjectId, classId, channel: 'email', enabled: false, + createdAt: new Date(), updatedAt: new Date(), + } as never); +} +const port = () => buildNotificationPreferences(rawDb, TENANT); + +describe('preference port', () => { + it('withholds a class the recipient switched off', async () => { + await seedContact(); + await mute('booking-confirmation'); + expect(await port().isMuted('booking-confirmation', ADDR)).toBe(true); + }); + + it('REFUSES to withhold a required class, even with a row that says to', async () => { + // The row exists and says enabled=false. It must not be honoured: the + // screen tells this recipient password-reset is always sent, and the + // send path has to agree with the screen or one of them is lying. + await seedContact(); + await mute('password-reset'); + expect(await port().isMuted('password-reset', ADDR)).toBe(false); + }); + + it('refuses to withhold a class it has never heard of — fail closed', async () => { + await seedContact(); + await mute('some.future.notification'); + expect(await port().isMuted('some.future.notification', ADDR)).toBe(false); + }); + + it('sends when there is no row at all — absence is not "off"', async () => { + await seedContact(); + expect(await port().isMuted('booking-confirmation', ADDR)).toBe(false); + }); + + it('honours a mute held in the OTHER id space — one person, one choice', async () => { + // An agent with an account who is also a contact on an inspection is + // the same human. Making them switch the same thing off twice is the + // kind of half-working control that is worse than none. + await seedContact(); + await db.insert(schema.users).values({ + id: 'u1', tenantId: TENANT, email: ADDR, passwordHash: 'x', role: 'owner', createdAt: new Date(), + } as never); + await mute('booking-confirmation', 'u1', 'user'); + expect(await port().isMuted('booking-confirmation', ADDR)).toBe(true); + }); + + it('is tenant-scoped — another tenant’s mute does not reach here', async () => { + await seedContact(); + await db.insert(schema.notificationPreferences).values({ + id: 'np-other', tenantId: 't-other', subjectKind: 'contact', subjectId: 'c1', + classId: 'booking-confirmation', channel: 'email', enabled: false, + createdAt: new Date(), updatedAt: new Date(), + } as never); + expect(await port().isMuted('booking-confirmation', ADDR)).toBe(false); + }); + + it('does not withhold from an address it cannot resolve to anyone', async () => { + expect(await port().isMuted('booking-confirmation', 'stranger@example.com')).toBe(false); + }); +}); + +describe('send boundary honours the port', () => { + /** + * Observes what reached the PROVIDER, not what the caller passed. An + * earlier version of this recorded the argument and passed while the + * filtered list never actually shrank — the assertion has to sit on the + * far side of the thing under test. + */ + class Probe extends EmailBaseService { + sent: string[][] = []; + constructor(prefs?: { isMuted(c: string, e: string): Promise }) { + const sent: string[][] = []; + super('re_test', 'from@x.com', 'Acme', undefined, undefined, undefined, + { sendEmail: async (m: { to: string[] }) => { sent.push(m.to); return { ok: true as const, id: 'm1' }; } }, + undefined, undefined, prefs); + this.sent = sent; + } + } + + const prefs = (muted: Record) => ({ + isMuted: async (c: string, e: string) => (muted[c] ?? []).includes(e), + }); + + it('drops a muted recipient and keeps the others', async () => { + const p = new Probe(prefs({ 'booking-confirmation': ['muted@x.com'] })); + await p.sendEmail(['muted@x.com', 'keep@x.com'], 'S', 'H', undefined, { classId: 'booking-confirmation' }); + expect(p.sent[0]).toEqual(['keep@x.com']); + }); + + it('does not send at all when every recipient muted it', async () => { + const p = new Probe(prefs({ 'booking-confirmation': ['a@x.com'] })); + const r = await p.sendEmail(['a@x.com'], 'S', 'H', undefined, { classId: 'booking-confirmation' }); + expect(r.delivered).toBe(false); + }); + + it('never consults the port for an UNCLASSIFIED send', async () => { + // No classId means the boundary cannot know what it is sending, and a + // preference it cannot name must never be applied by guesswork. + const isMuted = vi.fn(); + const p = new Probe({ isMuted }); + await p.sendEmail(['a@x.com'], 'S', 'H'); + expect(isMuted).not.toHaveBeenCalled(); + expect(p.sent[0]).toEqual(['a@x.com']); + }); + + it('sends when the lookup throws — a failed query must not silence mail', async () => { + const p = new Probe({ isMuted: async () => { throw new Error('db down'); } }); + await p.sendEmail(['a@x.com'], 'S', 'H', undefined, { classId: 'booking-confirmation' }); + expect(p.sent[0]).toEqual(['a@x.com']); + }); + + it('has no gate at all when no port is injected', async () => { + const p = new Probe(); + await p.sendEmail(['a@x.com'], 'S', 'H', undefined, { classId: 'booking-confirmation' }); + expect(p.sent[0]).toEqual(['a@x.com']); + }); +}); + +describe('the seam is actually connected', () => { + /** + * The port and the boundary can both be right while nothing joins them — + * a gate wired to nothing looks identical to a gate that passes. This + * assembles the service the production call sites assemble and asserts a + * real row in the real table stops a real provider call. + */ + const env = () => ({ + DB: rawDb, TENANT_CACHE: {} as never, JWT_SECRET: 'x'.repeat(32), + RESEND_API_KEY: 're_platform', SENDER_EMAIL: 'platform@example.com', + } as unknown as EmailServiceEnv); + + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ id: 'm1' }), { status: 200 }))); + }); + afterEach(() => vi.unstubAllGlobals()); + + it('a muted class does not reach the provider', async () => { + await seedContact(); + await mute('booking-confirmation'); + const svc = assembleTenantEmailService(env(), { dbSecrets: {} }, TENANT); + + const r = await svc.sendEmail([ADDR], 'S', 'H', undefined, { classId: 'booking-confirmation' }); + + expect(r.delivered).toBe(false); + expect(vi.mocked(fetch)).not.toHaveBeenCalled(); + }); + + it('the same recipient still gets a REQUIRED class', async () => { + await seedContact(); + await mute('password-reset'); + const svc = assembleTenantEmailService(env(), { dbSecrets: {} }, TENANT); + + await svc.sendEmail([ADDR], 'S', 'H', undefined, { classId: 'password-reset' }); + + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1); + }); +}); From 9eda941c635b1fe4f4429ddff7d3a4d576f073a4 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 31 Jul 2026 12:51:50 +0800 Subject: [PATCH 09/48] refactor(notifications): withdraw the fourth category, add recipientFacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit transactional / operational / marketing is a COMPLIANCE taxonomy — the vocabulary CAN-SPAM and GDPR reason in — and it says what the CONTENT is. `diagnostic` was added to it for the admin test send, and that was wrong: "only ever reaches whoever pressed the button" is a fact about the AUDIENCE. One column answering two different questions is the defect this work keeps finding elsewhere, so it should not have been introduced here. The audience fact moves to `recipientFacing: false`, which says what it means and leaves the taxonomy alone. The class still exists, and the send boundary can still name what it is sending — that part was never in question. A test now pins the vocabulary at three values, and was verified to fail by reintroducing the fourth. That test is the thing that would have caught this when it was written rather than two commits later. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq --- server/lib/notifications/classes.ts | 28 +++++++++++++++++------- tests/unit/notifications/classes.spec.ts | 19 ++++++++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/server/lib/notifications/classes.ts b/server/lib/notifications/classes.ts index c8cfe013a..44d6526d7 100644 --- a/server/lib/notifications/classes.ts +++ b/server/lib/notifications/classes.ts @@ -35,13 +35,15 @@ import type { AutomationChannel } from '../../services/automation/shared'; /** - * `diagnostic` is not a notification anyone receives by being someone — it - * only ever goes to whoever pressed the button. It is in this vocabulary - * because the send boundary must be able to say WHAT it is sending, and - * "nothing" is not an answer. The preferences screen filters it out: there is - * no preference to express about your own test. + * The three values spec §3.1 fixes. This is a COMPLIANCE taxonomy — the + * vocabulary CAN-SPAM and GDPR reason in — describing what the content IS. + * + * A fourth value was briefly added here for the admin test send, and that was + * wrong: "only ever reaches whoever pressed the button" is a fact about the + * AUDIENCE, not a content type. Putting it here would have made the taxonomy + * mean two things at once. It lives on `recipientFacing` instead. */ -type NotificationCategory = 'transactional' | 'operational' | 'marketing' | 'diagnostic'; +type NotificationCategory = 'transactional' | 'operational' | 'marketing'; export interface NotificationClass { /** Stable id. For registry-backed email this IS the template trigger. */ @@ -52,6 +54,16 @@ export interface NotificationClass { /** May this be switched off at all — by the operator OR the recipient? */ required: boolean; channels: AutomationChannel[]; + /** + * Does anyone RECEIVE this by being someone? Default true; omitted + * everywhere it is. + * + * `false` means the only recipient is whoever triggered it, so there is no + * standing relationship a preference could attach to — the preferences + * screen leaves it out. The class still exists because the send boundary + * must be able to name what it is sending, and "nothing" is not an answer. + */ + recipientFacing?: boolean; } export const NOTIFICATION_CLASSES: NotificationClass[] = [ @@ -93,8 +105,8 @@ export const NOTIFICATION_CLASSES: NotificationClass[] = [ // ─── not a notification to anyone but the sender // An admin sending their own message template to their own address to see // what it looks like. Classified so the boundary is never handed a send it - // cannot name; `diagnostic` keeps it off the recipient's screen. - { id: 'admin-test-send', label: 'Test send (admin)', category: 'diagnostic', required: true, channels: ['email', 'sms'] }, + // cannot name; `recipientFacing: false` keeps it off the recipient screen. + { id: 'admin-test-send', label: 'Test send (admin)', category: 'operational', required: true, channels: ['email', 'sms'], recipientFacing: false }, // ─── your inspection (spec §2.2) — the recipient may switch these off { id: 'booking-confirmation', label: 'Booking confirmation', category: 'transactional', required: false, channels: ['email', 'sms'] }, diff --git a/tests/unit/notifications/classes.spec.ts b/tests/unit/notifications/classes.spec.ts index 34daf628f..4780b6330 100644 --- a/tests/unit/notifications/classes.spec.ts +++ b/tests/unit/notifications/classes.spec.ts @@ -93,6 +93,25 @@ describe('notification classes', () => { expect(disagree).toEqual([]); }); + it('uses only the three categories §3.1 fixes — a compliance taxonomy, not a free field', () => { + // transactional / operational / marketing is the vocabulary CAN-SPAM and + // GDPR reason in: it says what the CONTENT is. A fourth value was once + // added here to mean "only the sender receives it", which is a fact + // about the AUDIENCE — two different questions sharing one field. That + // belongs on `recipientFacing`, and this keeps it there. + const allowed = new Set(['transactional', 'operational', 'marketing']); + const rogue = NOTIFICATION_CLASSES.filter((c) => !allowed.has(c.category)).map((c) => `${c.id}: ${c.category}`); + expect(rogue).toEqual([]); + }); + + it('keeps a non-recipient-facing class out of the screen, without hiding it from the boundary', () => { + const testSend = notificationClass('admin-test-send')!; + expect(testSend.recipientFacing).toBe(false); + // Everything a recipient can actually receive stays on the screen. + const hidden = NOTIFICATION_CLASSES.filter((c) => c.recipientFacing === false).map((c) => c.id); + expect(hidden).toEqual(['admin-test-send']); + }); + it('treats an unknown class as required — fail closed, never fail quiet', () => { expect(isSuppressible('some.future.notification')).toBe(false); }); From 7d9d04f40c13040df8a1d489279b37dabc46ed2e Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 31 Jul 2026 13:08:51 +0800 Subject: [PATCH 10/48] feat(notifications): every seeded automation rule now says what it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All scheduled and automatic sends are automation rules, so until now the entire rules layer reached the send boundary unnamed — and a preference cannot apply to something that cannot be named. The gate's allowlist entry for it is gone, and breaking the new call site was verified to make the gate fail. THE CLASS IS THE SEED, NOT THE TRIGGER. §2 already refuted the trigger: report.published alone carries five seeds, three to the same client saying different things, and §5.3 settles those outright — "report-ready is required and post-inspection follow-up / review request are not". One trigger-keyed class could not hold both answers, and `required` is the field the spec calls load-bearing. Seeds whose notification ALREADY has a class reuse it — Booking Confirmation, Report Ready, the invoice, both agreement ones, and the buyer's-agent report-ready that services/email/agent.ts also sends. The manual path and the automatic path are one notification arriving; two switches for one notification is how a control comes to half-work. Nine office alerts are nine classes. §2.5 lists them as one row for brevity; they are nine distinct events, and collapsing them would be the same mistake as keying on the trigger. Staff and inspector classes are `required` because §2.5 marks them Operator, not You. The operator's control is the rule's active flag, which is why one flag still suffices. Tenant-WRITTEN rules resolve to undefined and stay unclassified: they still send, they just cannot be muted by a recipient, and the operator can disable any rule. Inventing a per-rule class would put tenant data into a vocabulary the boundary fails closed on. Two gates earned themselves immediately. The seed-coverage one found three seeds a manual read had missed — their names contain an apostrophe, so they are double-quoted in the source and a single-quote regex skipped them silently; 29 seeds, not the 26 I had counted. And a channel gate caught `inspection-cancelled` declaring SMS: I transcribed that from §2.2, but the Cancellation Notice seed has no smsBody, so the screen would have rendered a switch for a message that can never be sent. §2 lists the channels the product INTENDS; the class must list the ones it has content for. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq --- scripts/check-notification-dispatch.mjs | 8 +- .../lib/notifications/automation-classes.ts | 86 ++++++++++++++++++ server/lib/notifications/classes.ts | 53 +++++++++++ server/services/automation/deliver-email.ts | 13 ++- .../notifications/automation-classes.spec.ts | 88 +++++++++++++++++++ tests/unit/notifications/classes.spec.ts | 21 ++++- 6 files changed, 261 insertions(+), 8 deletions(-) create mode 100644 server/lib/notifications/automation-classes.ts create mode 100644 tests/unit/notifications/automation-classes.spec.ts diff --git a/scripts/check-notification-dispatch.mjs b/scripts/check-notification-dispatch.mjs index c31b3d888..ead36fcae 100644 --- a/scripts/check-notification-dispatch.mjs +++ b/scripts/check-notification-dispatch.mjs @@ -110,11 +110,9 @@ const SEND_EMAIL_ALLOW = [ // the RenderResult, and `sendEmail` is the boundary being annotated. /^server\/services\/email\//, /^server\/lib\/email\//, - // The tenant-configured automation RULES layer. Its class model is a real - // open question (a rule's template is tenant-authored, so there is no fixed - // class id) and V2 decides it. Inventing one here would prejudge that, and a - // wrong class is worse than a stated absence. - /^server\/services\/automation\/deliver-email\.ts$/, + // The generic transport seam under the rules layer. It receives an already + // classified send from `deliver-email.ts` and only forwards it; there is no + // class to name at this level because it does not know what it is carrying. /^server\/lib\/automation-core\/deliver\.ts$/, ]; diff --git a/server/lib/notifications/automation-classes.ts b/server/lib/notifications/automation-classes.ts new file mode 100644 index 000000000..f76707490 --- /dev/null +++ b/server/lib/notifications/automation-classes.ts @@ -0,0 +1,86 @@ +/** + * Which notification class a SEEDED automation rule is. + * + * Every scheduled or automatic send in the product is an automation rule, so + * without this the whole rules layer reaches the send boundary unnamed — and a + * preference cannot apply to something that cannot be named. + * + * KEYED ON THE SEED, NOT THE TRIGGER. The trigger is the obvious key and it is + * wrong: `report.published` alone carries five seeds, three of them to the same + * client, and they do not agree on whether they may be switched off — spec §5.3 + * settles it as "report-ready is required and post-inspection follow-up / + * review request are not". One trigger-keyed class could not hold both answers. + * + * The key is `trigger::name`, which is exactly the identity `ensureSeeds` diffs + * on. A rule renamed by a tenant would already be re-seeded as a new rule by + * that mechanism, so this is no more fragile than what it sits beside. + * + * TENANT-CREATED RULES RETURN `undefined`, and that is a decision. They have no + * code-owned identity, so there is no class, so a recipient cannot mute them — + * but the operator can disable any rule outright (§5.3), so the control exists; + * it is the operator's. An invented per-rule class would put a tenant's data in + * a vocabulary the send boundary fails closed on. + */ + +/** `${trigger}::${name}` → notification class id. */ +const CLASS_BY_SEED: Record = { + // Seeds whose notification ALREADY has a class reuse it. The manual path + // and the automatic path are the same thing arriving, and two switches for + // one notification is how a control comes to half-work. + 'inspection.created::Booking Confirmation': 'booking-confirmation', + 'report.published::Report Ready': 'report-ready', + 'invoice.created::Invoice / Payment Request': 'payment-request', + 'inspection.created::Send agreement to client on inspection scheduled': 'agreement-request', + 'agreement.signed::Send signed agreement copy to client': 'agreement-signed', + // The buyer's agent already has a class for "a report is ready" — the + // agent-notification email sends it too (services/email/agent.ts). Same + // notification, two paths, one switch. + "report.published::Report Ready (Buyer's Agent)": 'agent-report-ready', + + // Client-facing, the recipient's call (§2.2 / §2.3). + 'inspection.confirmed::24-Hour Reminder': 'inspection-reminder', + 'inspection.cancelled::Cancellation Notice': 'inspection-cancelled', + 'report.amended::Report Updated': 'report-amended', + 'report.published::Report Ready (Listing Agent)': 'report-ready-listing-agent', + "inspection.created::Booking Confirmation (Buyer's Agent)": 'booking-confirmation-buyers-agent', + "report.amended::Report Updated (Buyer's Agent)": 'report-amended-buyers-agent', + 'event.created::Event Reminder (24h before)': 'event-reminder', + 'event.completed::Event Follow-up (results ready)': 'event-followup', + 'report.published::Post-inspection follow-up': 'post-inspection-followup', + 'report.published::Review request': 'review-request', + + // Inspector work notifications (§2.5) — Operator's call, not the individual's. + 'payment.received::Payment Received': 'inspector-payment-received', + 'agreement.signed::Notify inspector when client signs agreement': 'inspector-agreement-signed', + 'agreement.declined::Notify inspector when client declines agreement': 'inspector-agreement-declined', + 'agreement.viewed::Notify inspector when client views agreement': 'inspector-agreement-viewed', + + // Office alerts — nine events, nine classes. + 'booking.received::Office alert — new booking': 'office-alert-new-booking', + 'inspection.created::Office alert — inspection scheduled': 'office-alert-inspection-scheduled', + 'inspection.confirmed::Office alert — inspection confirmed': 'office-alert-inspection-confirmed', + 'inspection.cancelled::Office alert — inspection cancelled': 'office-alert-inspection-cancelled', + 'inspection.completed::Office alert — inspection completed': 'office-alert-inspection-completed', + 'report.published::Office alert — report published': 'office-alert-report-published', + 'invoice.created::Office alert — invoice created': 'office-alert-invoice-created', + 'payment.received::Office alert — payment received': 'office-alert-payment-received', + 'agreement.signed::Office alert — agreement signed': 'office-alert-agreement-signed', +}; + +/** + * The class this rule sends, or `undefined` for a rule the tenant wrote. + * + * `undefined` reaches the boundary as an unclassified send: it still goes out, + * it just cannot be muted (`isSuppressible` fails closed). That is the safe + * direction — the alternative is silently withholding mail on a guess. + */ +export function automationClassId( + rule: { name: string; trigger: string } | null | undefined, +): string | undefined { + if (!rule) return undefined; + return CLASS_BY_SEED[`${rule.trigger}::${rule.name}`]; +} + +/** Exposed for the drift gate in `tests/unit/notifications/`. */ +export const SEED_CLASS_KEYS = Object.keys(CLASS_BY_SEED); +export const SEED_CLASS_IDS = Object.values(CLASS_BY_SEED); diff --git a/server/lib/notifications/classes.ts b/server/lib/notifications/classes.ts index 44d6526d7..c7ee089cf 100644 --- a/server/lib/notifications/classes.ts +++ b/server/lib/notifications/classes.ts @@ -119,6 +119,59 @@ export const NOTIFICATION_CLASSES: NotificationClass[] = [ { id: 'agent-report-ready', label: 'A report is ready to read', category: 'transactional', required: false, channels: ['email'] }, { id: 'agent-invoice-paid', label: 'An invoice is paid', category: 'transactional', required: false, channels: ['email'] }, + // ─── automation rules the tenant did not write (spec §2.2, §2.3, §2.5) + // + // These are the SEEDED rules in `server/data/automation-seeds.ts` — every + // scheduled or automatic send in the product lives there. The class is the + // SEED's semantic identity, never its trigger: `report.published` alone + // carries five different seeds, and three of them go to the same client + // saying three different things. §5.3 settles the sharpest case outright — + // "report-ready is required and post-inspection follow-up / review request + // are not". A trigger-keyed class could not hold both answers. + // + // Seeds whose notification ALREADY has a class reuse it (Booking + // Confirmation, Report Ready, Invoice, the two agreement ones): the manual + // path and the automatic path are the same thing arriving, and two switches + // for one notification is how a control comes to half-work. + // + // The staff and inspector ones are `required` because §2.5 marks them + // Operator, not You: an individual cannot mute their own dispatch. The + // operator's control is the RULE's own active flag, which is why one + // `required` flag still suffices here. + { id: 'inspection-reminder', label: 'Reminder before your inspection', category: 'transactional', required: false, channels: ['email', 'sms'] }, + // email only: the Cancellation Notice seed carries no `smsBody`. §2.2 lists + // sms for this row, but that is the channel the product INTENDS, not one it + // has content for — and a switch for a message that can never be sent is a + // control that lies. + { id: 'inspection-cancelled', label: 'Your inspection was cancelled', category: 'transactional', required: false, channels: ['email'] }, + { id: 'report-amended', label: 'Your report was updated', category: 'transactional', required: false, channels: ['email'] }, + { id: 'report-ready-listing-agent', label: 'A report is ready (listing agent)', category: 'transactional', required: false, channels: ['email'] }, + { id: 'booking-confirmation-buyers-agent', label: 'An inspection you referred is booked', category: 'transactional', required: false, channels: ['email'] }, + { id: 'report-amended-buyers-agent', label: 'A report you follow was updated', category: 'transactional', required: false, channels: ['email'] }, + { id: 'event-reminder', label: 'Reminder before your appointment', category: 'transactional', required: false, channels: ['email'] }, + { id: 'event-followup', label: 'Your results are ready', category: 'transactional', required: false, channels: ['email'] }, + { id: 'post-inspection-followup', label: 'Following up after your inspection', category: 'transactional', required: false, channels: ['email'] }, + { id: 'review-request', label: 'How did we do?', category: 'marketing', required: false, channels: ['email'] }, + + // Inspector work notifications — §2.5, Operator's call, not the individual's. + { id: 'inspector-payment-received', label: 'A payment came in', category: 'operational', required: true, channels: ['email'] }, + { id: 'inspector-agreement-signed', label: 'A client signed the agreement', category: 'operational', required: true, channels: ['email'] }, + { id: 'inspector-agreement-declined', label: 'A client declined the agreement', category: 'operational', required: true, channels: ['email'] }, + { id: 'inspector-agreement-viewed', label: 'A client opened the agreement', category: 'operational', required: true, channels: ['email'] }, + + // Office alerts — nine events, nine classes. §2.5 lists them as one row for + // brevity; they are nine distinct things that happened, and collapsing them + // would be the same mistake as keying on the trigger. + { id: 'office-alert-new-booking', label: 'Office: a new booking arrived', category: 'operational', required: true, channels: ['in_app'] }, + { id: 'office-alert-inspection-scheduled', label: 'Office: an inspection was scheduled', category: 'operational', required: true, channels: ['in_app'] }, + { id: 'office-alert-inspection-confirmed', label: 'Office: an inspection was confirmed', category: 'operational', required: true, channels: ['in_app'] }, + { id: 'office-alert-inspection-cancelled', label: 'Office: an inspection was cancelled', category: 'operational', required: true, channels: ['in_app'] }, + { id: 'office-alert-inspection-completed', label: 'Office: an inspection was completed', category: 'operational', required: true, channels: ['in_app'] }, + { id: 'office-alert-report-published', label: 'Office: a report was published', category: 'operational', required: true, channels: ['in_app'] }, + { id: 'office-alert-invoice-created', label: 'Office: an invoice was created', category: 'operational', required: true, channels: ['in_app'] }, + { id: 'office-alert-payment-received', label: 'Office: a payment was received', category: 'operational', required: true, channels: ['in_app'] }, + { id: 'office-alert-agreement-signed', label: 'Office: an agreement was signed', category: 'operational', required: true, channels: ['in_app'] }, + // ─── concierge (spec §2.4) { id: 'concierge-client-confirm', label: 'Booking confirmed', category: 'transactional', required: false, channels: ['email'] }, { id: 'concierge-inspector-review', label: 'A booking needs your review', category: 'operational', required: false, channels: ['email'] }, diff --git a/server/services/automation/deliver-email.ts b/server/services/automation/deliver-email.ts index 9d1437afe..4f928f72e 100644 --- a/server/services/automation/deliver-email.ts +++ b/server/services/automation/deliver-email.ts @@ -7,6 +7,7 @@ import { logger } from '../../lib/logger'; import { deliverAction } from '../../lib/automation-core'; import { buildBaseTemplateVars } from './template-vars'; import { createOiTemplateStore } from './template-store'; +import { automationClassId } from '../../lib/notifications/automation-classes'; import { oiClock } from './shared'; import type { FlushInspection } from './shared'; import type { EmailService } from '../email.service'; @@ -163,7 +164,17 @@ export async function deliverTemplatedEmail( }; const transport = { sendEmail: async (a: { to: string; subject: string; html: string }) => { - const { delivered } = await emailSvc.sendEmail([a.to], a.subject, a.html); + // The rules layer names what it is sending, like every other + // dispatch path. A tenant-written rule has no code-owned identity + // and resolves to undefined — unclassified, so it still goes out, + // it just cannot be muted by a recipient. + // Conditional spread, not `classId: maybeUndefined` — + // exactOptionalPropertyTypes distinguishes "absent" from "present + // and undefined", and absent is what an unclassified send means. + const classId = automationClassId(automation); + const { delivered } = await emailSvc.sendEmail( + [a.to], a.subject, a.html, undefined, classId ? { classId } : {}, + ); // OI maps "not delivered" (e.g. email not configured) to a // SKIPPED log, not a failure. Encode that as a sentinel the // logger adapter below translates. diff --git a/tests/unit/notifications/automation-classes.spec.ts b/tests/unit/notifications/automation-classes.spec.ts new file mode 100644 index 000000000..01b967dca --- /dev/null +++ b/tests/unit/notifications/automation-classes.spec.ts @@ -0,0 +1,88 @@ +/** + * Every seeded rule is a notification someone receives, so every seeded rule + * must be able to say what it is. + * + * The map and the seed list are two files that have to agree, which is the + * shape this codebase keeps finding bugs in. Asserted, not described. + */ +import { describe, it, expect } from 'vitest'; +import { AUTOMATION_SEEDS } from '../../../server/data/automation-seeds'; +import { automationClassId, SEED_CLASS_KEYS, SEED_CLASS_IDS } from '../../../server/lib/notifications/automation-classes'; +import { notificationClass } from '../../../server/lib/notifications/classes'; + +/** + * Classes that ALSO have a non-automation sender, so the seed is not the only + * thing that decides which channels are possible. + */ +const SHARED_WITH_OTHER_PATHS = new Set([ + 'booking-confirmation', 'report-ready', 'payment-request', + 'agreement-request', 'agreement-signed', 'agent-report-ready', +]); + +describe('automation seed classes', () => { + it('gives every seeded rule a class — a new seed cannot arrive unnamed', () => { + const unnamed = AUTOMATION_SEEDS + .filter((s) => !automationClassId(s)) + .map((s) => `${s.trigger}::${s.name}`); + expect(unnamed, 'add these to CLASS_BY_SEED in automation-classes.ts').toEqual([]); + }); + + it('maps only to classes that exist', () => { + const unknown = SEED_CLASS_IDS.filter((id) => !notificationClass(id)); + expect(unknown).toEqual([]); + }); + + it('has no entry for a seed that no longer exists', () => { + // A stale entry is how a map starts describing a system that changed + // underneath it. Cheap to catch, invisible otherwise. + const live = new Set(AUTOMATION_SEEDS.map((s) => `${s.trigger}::${s.name}`)); + expect(SEED_CLASS_KEYS.filter((k) => !live.has(k))).toEqual([]); + }); + + it('keeps the three report.published client seeds apart', () => { + // The whole reason the key is the seed and not the trigger. These three + // go to the same person off the same event and say different things, + // and spec §5.3 gives them different answers about muting. + const ids = ['Report Ready', 'Post-inspection follow-up', 'Review request'] + .map((name) => automationClassId({ name, trigger: 'report.published' })); + expect(new Set(ids).size).toBe(3); + expect(notificationClass(ids[0]!)!.required).toBe(true); + expect(notificationClass(ids[1]!)!.required).toBe(false); + expect(notificationClass(ids[2]!)!.required).toBe(false); + }); + + it('declares only channels the seed can actually deliver on', () => { + // A class's `channels` is what the preferences screen renders a control + // for. Promising SMS for a seed with no `smsBody` puts a switch in + // front of someone for a message that can never be sent — and the + // §2 table, which is where these were transcribed from, lists channels + // the product INTENDS, not the ones it has content for. + const wrong: string[] = []; + for (const seed of AUTOMATION_SEEDS) { + const id = automationClassId(seed); + if (!id) continue; + const cls = notificationClass(id)!; + const s = seed as { smsBody?: string; channels?: string[]; inAppTitle?: string }; + const deliverable = new Set(); + const seedChannels = s.channels ?? ['email']; + if (seedChannels.includes('email')) deliverable.add('email'); + if (s.smsBody) deliverable.add('sms'); + if (seedChannels.includes('in_app') || s.inAppTitle) deliverable.add('in_app'); + for (const ch of cls.channels) { + // A class may be shared with a non-automation path that has the + // channel, so only flag a channel NO path can deliver. + if (!deliverable.has(ch) && !SHARED_WITH_OTHER_PATHS.has(id)) { + wrong.push(`${id} declares ${ch}, but "${seed.name}" cannot send it`); + } + } + } + expect(wrong).toEqual([]); + }); + + it('returns undefined for a rule the tenant wrote', () => { + // Unclassified, therefore unmutable by a recipient — the operator can + // still disable the rule. Never a guess. + expect(automationClassId({ name: 'My own rule', trigger: 'report.published' })).toBeUndefined(); + expect(automationClassId(null)).toBeUndefined(); + }); +}); diff --git a/tests/unit/notifications/classes.spec.ts b/tests/unit/notifications/classes.spec.ts index 4780b6330..476dabe6a 100644 --- a/tests/unit/notifications/classes.spec.ts +++ b/tests/unit/notifications/classes.spec.ts @@ -38,9 +38,19 @@ const NEVER_OFF = [ // A one-off share to a typed-in address: no account, no relationship, so no // preference can exist. See the third `required: true` case in classes.ts. 'repair-request-share', - // Only ever sent to whoever pressed the button — see the `diagnostic` - // category. Nobody else can have a preference about it. + // Only ever sent to whoever pressed the button — see `recipientFacing`. + // Nobody else can have a preference about it. 'admin-test-send', + // §2.5 — work notifications to employees. The company decides; an + // individual cannot mute their own dispatch. The operator's control is the + // automation rule's own active flag, not this field. + 'inspector-payment-received', 'inspector-agreement-signed', + 'inspector-agreement-declined', 'inspector-agreement-viewed', + 'office-alert-new-booking', 'office-alert-inspection-scheduled', + 'office-alert-inspection-confirmed', 'office-alert-inspection-cancelled', + 'office-alert-inspection-completed', 'office-alert-report-published', + 'office-alert-invoice-created', 'office-alert-payment-received', + 'office-alert-agreement-signed', ]; /** Spec §2.2-§2.4 — the recipient's call. */ @@ -49,6 +59,13 @@ const RECIPIENT_MAY_MUTE = [ 'agent-new-referral', 'agent-report-ready', 'agent-invoice-paid', 'concierge-client-confirm', 'concierge-inspector-review', 'concierge-confirmed-agent', 'concierge-cancelled-agent', + // §2.2 / §2.3 — seeded automation rules the recipient may switch off. + // §5.3 settles the sharpest pair outright: report-ready is required, + // post-inspection-followup and review-request are not. + 'inspection-reminder', 'inspection-cancelled', 'report-amended', + 'report-ready-listing-agent', 'booking-confirmation-buyers-agent', + 'report-amended-buyers-agent', 'event-reminder', 'event-followup', + 'post-inspection-followup', 'review-request', ]; describe('notification classes', () => { From 92f51fb1e6f181469d3618d40e92e1b7bad3d1e3 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 31 Jul 2026 13:16:47 +0800 Subject: [PATCH 11/48] test(notifications): pin the two consequences D3 and D5 accepted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both deviations were confirmed as intentional, which means the thing to protect is no longer the decision but its consequence — the part a later change would narrow without noticing it was narrowing anything. D3: the boundary resolves an address in BOTH id spaces, so a mute crosses identities. The existing test covered the user→contact direction; this adds contact→user, and the case that actually bites — a tenant who also keeps a staff address in `contacts`, where one person's mute governs both identities. That is the intent (one human, one inbox), and narrowing the lookup to a single space now fails here instead of quietly halving the control. A second test pins that the crossing stops at the tenant boundary: the same address in another tenant is a different relationship. D5: two contacts sharing a number means one person's STOP withholds the other's message. Not introduced by the shared gate — the inbound STOP webhook already records revocation against EVERY contact matching the number, so the ledger was always number-shaped, and reading it any other way would honour a revocation for one row while ignoring it for its twin. Both were verified to go red under exactly the narrowing they warn about (consult only the addressed contact / only one id space), and green again when restored. A test that has never failed is a test that has proved nothing. One fixture bug found on the way: `users.tenant_id` carries a legacy FK, so the cross-tenant case needs the other tenant seeded first — the test was failing on its own setup, not on a leak. The in-app half of v2 is NOT included and is not a small addition: nine insert sites across three files need a class first, which is the same shape of work P2 did for email. Enforcing preferences only where a class happens to be available would build the half-working control this whole change exists to avoid. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq --- tests/unit/messaging/sms-send-gate.spec.ts | 20 ++++++++++ .../preference-enforcement.spec.ts | 39 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/tests/unit/messaging/sms-send-gate.spec.ts b/tests/unit/messaging/sms-send-gate.spec.ts index 0c4fbf45f..149422e22 100644 --- a/tests/unit/messaging/sms-send-gate.spec.ts +++ b/tests/unit/messaging/sms-send-gate.spec.ts @@ -125,6 +125,26 @@ describe('smsSendGate — purpose: test', () => { .toEqual({ allowed: false, reason: 'sms opt-out' }); }); + it('blocks when ANY contact on that number revoked — deliberate, and not new', async () => { + // Deviation D5 in the spec. Two people sharing a number (a couple, an + // office line) means one person's STOP withholds the other's message. + // That is not introduced here: the inbound STOP webhook already records + // a revocation against EVERY contact matching the number, so the ledger + // was always number-shaped. Reading it any other way would honour a + // revocation for one row and ignore it for its twin. + // + // Pinned so a later change that narrows this to "the addressed contact + // only" fails here rather than quietly resuming texts to a number that + // asked us to stop. + await seedContact('c-quiet', PHONE); + await seedContact('c-loud', PHONE); + await seedConsent('s-loud', 'c-loud', 'granted'); + await seedConsent('s-quiet', 'c-quiet', 'revoked'); + + expect(await gate({ purpose: 'test' })) + .toEqual({ allowed: false, reason: 'sms opt-out' }); + }); + it('leaves an unrelated contact alone', async () => { await seedContact('c1', '+15550001111'); await seedConsent('s1', 'c1', 'revoked'); diff --git a/tests/unit/notifications/preference-enforcement.spec.ts b/tests/unit/notifications/preference-enforcement.spec.ts index 18fc59c2a..71ef3b0ea 100644 --- a/tests/unit/notifications/preference-enforcement.spec.ts +++ b/tests/unit/notifications/preference-enforcement.spec.ts @@ -97,6 +97,45 @@ describe('preference port', () => { expect(await port().isMuted('booking-confirmation', ADDR)).toBe(true); }); + it('crosses the id spaces in BOTH directions, and that is deliberate', async () => { + // Deviation D3 in the spec. `autoLinkSameEmail` exists specifically so + // one email is both a `users` row and several `contacts` rows, so the + // boundary honours a mute held on either side. The consequence worth + // pinning: if a tenant also keeps a STAFF address in `contacts`, that + // person's mute crosses both identities. That is the intent — they are + // one human with one inbox — and a later change that narrows the lookup + // to a single space must fail here rather than quietly halve the control. + await seedContact(); + await db.insert(schema.users).values({ + id: 'u1', tenantId: TENANT, email: ADDR, passwordHash: 'x', role: 'inspector', createdAt: new Date(), + } as never); + + // Muted on the CONTACT side; the address is also a user. + await mute('booking-confirmation', 'c1', 'contact'); + expect(await port().isMuted('booking-confirmation', ADDR)).toBe(true); + }); + + it('does not cross identities ACROSS tenants', async () => { + // The cross-identity rule is inside one tenant. The same address being + // a user here and a contact somewhere else is two relationships, not one. + await seedContact(); + // `users.tenant_id` carries a legacy FK, so the other tenant has to + // exist before a user can live in it. + await db.insert(schema.tenants).values({ + id: 't-other', name: 'Other', slug: 't-other', status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + } as never); + await db.insert(schema.users).values({ + id: 'u-other', tenantId: 't-other', email: ADDR, passwordHash: 'x', role: 'owner', createdAt: new Date(), + } as never); + await db.insert(schema.notificationPreferences).values({ + id: 'np-x', tenantId: 't-other', subjectKind: 'user', subjectId: 'u-other', + classId: 'booking-confirmation', channel: 'email', enabled: false, + createdAt: new Date(), updatedAt: new Date(), + } as never); + expect(await port().isMuted('booking-confirmation', ADDR)).toBe(false); + }); + it('is tenant-scoped — another tenant’s mute does not reach here', async () => { await seedContact(); await db.insert(schema.notificationPreferences).values({ From 124d84b7131a5efe2a97cb1d7da28605350838c0 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 31 Jul 2026 13:30:05 +0800 Subject: [PATCH 12/48] feat(notifications): in-app honours the same preference decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notice header IS the in-app delivery, so withholding one means not writing the row. `insertNoticeHeader` now returns null when the recipient switched that class off, and the fan-out marks those logs skipped with a reason rather than leaving a pending row that never resolves. ONE DECISION, TWO CHANNELS. The subject-keyed core is extracted and shared rather than restated — the required check above all, because that is the thing keeping the screen's promise and the send path's behaviour in agreement. A second copy of it is how the two would drift into disagreeing about what "always sent" means. In-app needs none of the address resolution email does: a header is `user_id XOR contact_id` by construction, so the subject is already in hand. That is why the shared piece is the decision, not the port. The class comes from the RULE, threaded like the wording already was — two rules on one event are two different things to have a preference about, so a per-firing class would be wrong for the same reason a per-firing title was. Unclassified headers are always written, matching the email boundary: a notice that cannot say what it is must never be silenced by guesswork. Required classes are always written too — §2.5, an individual cannot mute their own dispatch. Verified by unwiring the check and watching the enforcement test go red. THE GATE FOUND A DEFECT IN ITSELF. `lint:notification-dispatch` required a literal `classId:`, but the idiom under exactOptionalPropertyTypes is a conditional spread — `classId ? { classId } : {}` — which is shorthand and has no colon. It reported a correctly classified send as unclassified. Worth noting how it survived a commit: the gate lives only in the full lint run, so nothing ran it between the type fix that introduced the shorthand and the commit after it. Two fixture bugs on the way, both the same shape: `users.tenant_id` and `notifications.user_id` carry legacy FKs, so a test row has to exist before another row can point at it. Both failed on their own setup, not on the behaviour under test. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq --- scripts/check-notification-dispatch.mjs | 7 +- server/lib/notifications/preference-port.ts | 93 +++++++++++++------ server/services/automation/manual-log.ts | 4 +- server/services/automation/notice-headers.ts | 45 ++++++++- server/services/automation/trigger.ts | 11 +++ tests/unit/automations/notice-headers.spec.ts | 51 ++++++++++ .../unit/automations/staff-recipients.spec.ts | 2 + .../preference-enforcement.spec.ts | 48 +++++++++- 8 files changed, 229 insertions(+), 32 deletions(-) diff --git a/scripts/check-notification-dispatch.mjs b/scripts/check-notification-dispatch.mjs index ead36fcae..c38e2015f 100644 --- a/scripts/check-notification-dispatch.mjs +++ b/scripts/check-notification-dispatch.mjs @@ -150,7 +150,12 @@ for (const file of SCANNED) { if (!allowed(r, SEND_EMAIL_ALLOW)) { for (const m of source.matchAll(SEND_EMAIL_RE)) { const args = callArgs(source, m.index + m[0].length - 1); - if (!/\bclassId\s*:/.test(args)) { + // `classId:` OR the shorthand `{ classId }`. Under + // exactOptionalPropertyTypes the idiom for an optional class is a + // conditional spread — `classId ? { classId } : {}` — which has no + // colon, and requiring one reported a classified send as unclassified. + // The gate found that on itself. + if (!/\bclassId\s*[:,}]/.test(args)) { add(file, raw, m.index, 'unclassified-send', '.sendEmail( … ) with no classId'); } } diff --git a/server/lib/notifications/preference-port.ts b/server/lib/notifications/preference-port.ts index ed783439f..f594ea2d1 100644 --- a/server/lib/notifications/preference-port.ts +++ b/server/lib/notifications/preference-port.ts @@ -18,6 +18,65 @@ export interface NotificationPreferencePort { isMuted(classId: string, email: string): Promise; } +/** A row in `notification_preferences` is keyed on one of these. */ +export interface PreferenceSubject { + kind: 'user' | 'contact'; + id: string; +} + +/** + * The decision itself, for callers that already KNOW who the subject is. + * + * The in-app path does: a notice header is `user_id XOR contact_id` by + * construction, so it has the subject in hand and needs none of the + * address-resolution the email path exists to do. Sharing this function rather + * than the port is what keeps the two channels answering the same question — + * the required check in particular must not exist twice. + * + * THE REQUIRED CHECK COMES FIRST, before any lookup: a class the recipient is + * told is always sent stays unmutable even if a row says otherwise, and + * `isSuppressible` fails closed on ids it has never heard of. + */ +export async function isPreferenceMuted( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + d: { select: (...args: any[]) => any }, + tenantId: string, + classId: string, + channel: 'email' | 'sms' | 'in_app', + subjects: PreferenceSubject[], +): Promise { + if (!isSuppressible(classId)) return false; + if (subjects.length === 0) return false; + + const byKind = (kind: 'user' | 'contact') => + subjects.filter((s) => s.kind === kind).map((s) => s.id); + const userIds = byKind('user'); + const contactIds = byKind('contact'); + + const match = [ + userIds.length + ? and(eq(notificationPreferences.subjectKind, 'user'), inArray(notificationPreferences.subjectId, userIds)) + : undefined, + contactIds.length + ? and(eq(notificationPreferences.subjectKind, 'contact'), inArray(notificationPreferences.subjectId, contactIds)) + : undefined, + ].filter(Boolean); + + const row = await d.select({ enabled: notificationPreferences.enabled }) + .from(notificationPreferences) + .where(and( + eq(notificationPreferences.tenantId, tenantId), + eq(notificationPreferences.classId, classId), + eq(notificationPreferences.channel, channel), + match.length === 1 ? match[0] : or(...match), + eq(notificationPreferences.enabled, false), + )) + .get(); + + // Absence is not "off": no row means the class default applies. + return !!row; +} + /** * Build the tenant-scoped preference port for the email channel. * @@ -42,8 +101,7 @@ export interface NotificationPreferencePort { export function buildNotificationPreferences(db: D1Database, tenantId: string): NotificationPreferencePort { return { async isMuted(classId: string, email: string): Promise { - // A required class is never withheld — checked before any lookup, so - // no row can ever override it. + // Cheap exit before touching the DB at all. if (!isSuppressible(classId)) return false; const d = drizzle(db); @@ -55,32 +113,11 @@ export function buildNotificationPreferences(db: D1Database, tenantId: string): d.select({ id: contacts.id }).from(contacts) .where(and(eq(contacts.tenantId, tenantId), eq(contacts.email, normalized))).all(), ]); - const userIds = userRows.map((r) => r.id); - const contactIds = contactRows.map((r) => r.id); - if (userIds.length === 0 && contactIds.length === 0) return false; - - const subjectMatch = [ - userIds.length - ? and(eq(notificationPreferences.subjectKind, 'user'), inArray(notificationPreferences.subjectId, userIds)) - : undefined, - contactIds.length - ? and(eq(notificationPreferences.subjectKind, 'contact'), inArray(notificationPreferences.subjectId, contactIds)) - : undefined, - ].filter(Boolean); - - const row = await d.select({ enabled: notificationPreferences.enabled }) - .from(notificationPreferences) - .where(and( - eq(notificationPreferences.tenantId, tenantId), - eq(notificationPreferences.classId, classId), - eq(notificationPreferences.channel, 'email'), - subjectMatch.length === 1 ? subjectMatch[0] : or(...subjectMatch), - eq(notificationPreferences.enabled, false), - )) - .get(); - - // Absence is not "off": no row means the class default applies. - return !!row; + const subjects: PreferenceSubject[] = [ + ...userRows.map((r) => ({ kind: 'user' as const, id: r.id })), + ...contactRows.map((r) => ({ kind: 'contact' as const, id: r.id })), + ]; + return isPreferenceMuted(d, tenantId, classId, 'email', subjects); }, }; } diff --git a/server/services/automation/manual-log.ts b/server/services/automation/manual-log.ts index 8449eff49..00febeb63 100644 --- a/server/services/automation/manual-log.ts +++ b/server/services/automation/manual-log.ts @@ -41,7 +41,9 @@ export function makeManualSendLogger( type: 'manual.send', title: noticeTitle, inspectionId, entityType: 'inspection', entityId: inspectionId, }); - headerByContact.set(row.contactId, noticeId); + // A manual send names no class, so the header is always + // written — the guard is for the type, not a real branch. + if (noticeId) headerByContact.set(row.contactId, noticeId); } } await db.insert(automationLogs).values({ diff --git a/server/services/automation/notice-headers.ts b/server/services/automation/notice-headers.ts index 18b4c98cd..643cd84ea 100644 --- a/server/services/automation/notice-headers.ts +++ b/server/services/automation/notice-headers.ts @@ -18,6 +18,7 @@ import { automationLogs } from '../../lib/db/schema'; import { insertNotificationRow } from '../notification.service'; import { nanoid } from 'nanoid'; import { isStaffRecipient } from './shared'; +import { isPreferenceMuted, type PreferenceSubject } from '../../lib/notifications/preference-port'; export interface NoticeHeaderInput { tenantId: string; @@ -32,12 +33,22 @@ export interface NoticeHeaderInput { entityType?: string | null; entityId?: string | null; metadata?: Record | null; + /** + * A `NOTIFICATION_CLASSES` id. When given, the recipient's in-app + * preference is consulted before the header is written — the notice IS the + * in-app delivery, so withholding it means not writing the row. + * + * Absent ⇒ unclassified ⇒ always written, matching the email boundary's + * posture: a send that cannot say what it is must never be silenced by + * guesswork. + */ + classId?: string | undefined; } // Accept the D1 drizzle instance or the better-sqlite3 test db — same builder surface. type AnyDb = { insert: (...args: never[]) => unknown }; -export async function insertNoticeHeader(rawDb: AnyDb, input: NoticeHeaderInput): Promise { +export async function insertNoticeHeader(rawDb: AnyDb, input: NoticeHeaderInput): Promise { const userId = input.userId ?? null; const contactId = input.contactId ?? null; if ((userId === null) === (contactId === null)) { @@ -47,6 +58,22 @@ export async function insertNoticeHeader(rawDb: AnyDb, input: NoticeHeaderInput) } // eslint-disable-next-line @typescript-eslint/no-explicit-any const db = rawDb as any; + + // The recipient's own kill switch, sharing the decision with the email + // boundary rather than restating it — the required check in particular + // must not exist in two places, or the screen's promise and the send + // path's behaviour can drift apart. FAIL-OPEN on a lookup error: a failed + // query must never be the reason a notice was not written. + if (input.classId) { + const subject: PreferenceSubject = userId + ? { kind: 'user', id: userId } + : { kind: 'contact', id: contactId! }; + let muted = false; + try { + muted = await isPreferenceMuted(db, input.tenantId, input.classId, 'in_app', [subject]); + } catch { muted = false; } + if (muted) return null; + } const id = nanoid(); // The row write itself belongs to NotificationService — one owner for this // table (lint:provider-helpers). What stays here is what a header MEANS: @@ -97,6 +124,12 @@ export async function createHeadersForInsertedLogs( * single title for the whole firing would silently pick one of them. */ wordingFor: (automationId: string | null) => NoticeWording, + /** + * The notification class for one rule's notice — per-RULE for the same + * reason `wordingFor` is: two rules on one event are two different things + * to have a preference about. + */ + classFor: (automationId: string | null) => string | undefined, inserted: Array<{ id: string; automationId: string | null; sendAt: Date | number; recipientContactId: string | null; recipientRoleKey: string | null }>, ): Promise { @@ -116,6 +149,7 @@ export async function createHeadersForInsertedLogs( } for (const g of groups.values()) { const wording = wordingFor(g.automationId); + const classId = classFor(g.automationId); const noticeId = await insertNoticeHeader(db, { tenantId: ctx.tenantId, userId: g.userId, @@ -127,7 +161,16 @@ export async function createHeadersForInsertedLogs( entityType: 'inspection', entityId: ctx.inspectionId, metadata: g.automationId ? { automationId: g.automationId } : null, + ...(classId ? { classId } : {}), }); + if (noticeId === null) { + // The recipient switched this off. The notice IS the in-app + // delivery, so there is nothing to link — record WHY rather than + // leaving a pending row that never resolves. + await db.update(automationLogs).set({ status: 'skipped', error: 'muted by recipient' }) + .where(inArray(automationLogs.id, g.ids)); + continue; + } await db.update(automationLogs).set({ noticeId }) .where(inArray(automationLogs.id, g.ids)); } diff --git a/server/services/automation/trigger.ts b/server/services/automation/trigger.ts index 9cdaac635..17d82128f 100644 --- a/server/services/automation/trigger.ts +++ b/server/services/automation/trigger.ts @@ -6,6 +6,7 @@ import { createHeadersForInsertedLogs, type NoticeWording } from './notice-heade import { logger } from '../../lib/logger'; import { createOiTemplateStore } from './template-store'; import { resolveRuleRecipients, type ResolvedRecipient } from './recipients'; +import { automationClassId } from '../../lib/notifications/automation-classes'; import { interpolate } from './shared'; import type { AutomationChannel, RecipientKind, Constructor, TriggerContext } from './shared'; import type { AutomationBase, HasEnsureSeeds, HasParseChannels } from './shared'; @@ -181,9 +182,19 @@ export function AutomationTrigger(); + for (const rule of filteredRules) { + const cls = automationClassId(rule); + if (cls) classByRule.set(rule.id, cls); + } await createHeadersForInsertedLogs( db, ctx, (automationId) => (automationId && wordingByRule.get(automationId)) || fallback, + (automationId) => (automationId ? classByRule.get(automationId) : undefined), inserted, ); } catch (err) { diff --git a/tests/unit/automations/notice-headers.spec.ts b/tests/unit/automations/notice-headers.spec.ts index 1f61eebd0..7eb01fcc4 100644 --- a/tests/unit/automations/notice-headers.spec.ts +++ b/tests/unit/automations/notice-headers.spec.ts @@ -131,6 +131,57 @@ describe('insertNoticeHeader — the XOR invariant the DB cannot express', () => }); }); +describe('insertNoticeHeader honours the recipient preference', () => { + /** + * The notice IS the in-app delivery, so withholding it means not writing + * the row. The decision is shared with the email boundary rather than + * restated here — particularly the required check, which is what keeps the + * screen's promise and the send path's behaviour in agreement. + */ + async function mute(classId: string, subjectId: string, kind: 'user' | 'contact' = 'contact') { + await db.insert(schema.notificationPreferences).values({ + id: `np-${classId}-${subjectId}`, tenantId: TENANT, subjectKind: kind, subjectId, + classId, channel: 'in_app', enabled: false, createdAt: new Date(), updatedAt: new Date(), + } as never); + } + + it('writes nothing and returns null when the recipient switched it off', async () => { + await mute('message-notification', 'c-muted'); + const id = await insertNoticeHeader(db, { + tenantId: TENANT, userId: null, contactId: 'c-muted', + type: 'message.received', title: 'New message', classId: 'message-notification', + }); + expect(id).toBeNull(); + const rows = await db.select().from(schema.notifications).all(); + expect(rows).toHaveLength(0); + }); + + it('still writes a REQUIRED notice — an individual cannot mute their dispatch', async () => { + // `notifications.user_id` carries a legacy FK, so the staff row has to + // exist before a header can point at it. + await db.insert(schema.users).values({ + id: 'u-staff', tenantId: TENANT, email: 'staff@example.com', + passwordHash: 'x', role: 'owner', createdAt: new Date(), + } as never); + await mute('office-alert-new-booking', 'u-staff', 'user'); + const id = await insertNoticeHeader(db, { + tenantId: TENANT, userId: 'u-staff', contactId: null, + type: 'booking.received', title: 'New booking', classId: 'office-alert-new-booking', + }); + expect(id).not.toBeNull(); + expect(await db.select().from(schema.notifications).all()).toHaveLength(1); + }); + + it('writes an UNCLASSIFIED notice — a header that cannot say what it is is never silenced', async () => { + await mute('message-notification', 'c-muted'); + const id = await insertNoticeHeader(db, { + tenantId: TENANT, userId: null, contactId: 'c-muted', + type: 'message.received', title: 'New message', + }); + expect(id).not.toBeNull(); + }); +}); + describe('manual send logger creates headers (C1)', () => { it('one header per contact per batch; rows without a contact keep notice_id NULL', async () => { const insp = 'insp-c1-manual'; diff --git a/tests/unit/automations/staff-recipients.spec.ts b/tests/unit/automations/staff-recipients.spec.ts index cc202a12e..471e55cbd 100644 --- a/tests/unit/automations/staff-recipients.spec.ts +++ b/tests/unit/automations/staff-recipients.spec.ts @@ -141,6 +141,8 @@ describe('staff recipients (B2)', () => { db, { tenantId: T, inspectionId: INSP, triggerEvent: 'report.published' }, () => ({ title: 'Report published', body: null }), + // No class: this fixture exercises the XOR, not the preference gate. + () => undefined, [{ id: 'log-staff-1', automationId: null, sendAt: new Date(0), recipientContactId: 'u-owner', recipientRoleKey: STAFF_ROLE_KEY }], ); diff --git a/tests/unit/notifications/preference-enforcement.spec.ts b/tests/unit/notifications/preference-enforcement.spec.ts index 71ef3b0ea..3ee015d18 100644 --- a/tests/unit/notifications/preference-enforcement.spec.ts +++ b/tests/unit/notifications/preference-enforcement.spec.ts @@ -16,7 +16,7 @@ vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; // eslint-disable-next-line import/order -import { buildNotificationPreferences } from '../../../server/lib/notifications/preference-port'; +import { buildNotificationPreferences, isPreferenceMuted } from '../../../server/lib/notifications/preference-port'; // eslint-disable-next-line import/order import { EmailBaseService } from '../../../server/services/email/base'; // eslint-disable-next-line import/order @@ -246,3 +246,49 @@ describe('the seam is actually connected', () => { expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1); }); }); + +describe('in-app honours the same decision', () => { + /** + * A notice header is `user_id XOR contact_id` by construction, so the in-app + * path already knows its subject and needs none of the address resolution + * the email path exists to do. What it must NOT have is its own copy of the + * decision — particularly the required check, which is the thing keeping the + * screen's promise and the send path's behaviour in agreement. + */ + it('withholds an in-app notice the recipient switched off', async () => { + await db.insert(schema.notificationPreferences).values({ + id: 'np-inapp', tenantId: TENANT, subjectKind: 'contact', subjectId: 'c1', + classId: 'message-notification', channel: 'in_app', enabled: false, + createdAt: new Date(), updatedAt: new Date(), + } as never); + expect(await isPreferenceMuted(db, TENANT, 'message-notification', 'in_app', + [{ kind: 'contact', id: 'c1' }])).toBe(true); + }); + + it('does not confuse the two channels — muting email leaves in-app alone', async () => { + await db.insert(schema.notificationPreferences).values({ + id: 'np-email-only', tenantId: TENANT, subjectKind: 'contact', subjectId: 'c1', + classId: 'message-notification', channel: 'email', enabled: false, + createdAt: new Date(), updatedAt: new Date(), + } as never); + expect(await isPreferenceMuted(db, TENANT, 'message-notification', 'in_app', + [{ kind: 'contact', id: 'c1' }])).toBe(false); + }); + + it('refuses to withhold a required in-app notice — office alerts are dispatch', async () => { + // §2.5: an individual cannot mute their own dispatch. The operator's + // control is the rule's active flag, not this row. + await db.insert(schema.notificationPreferences).values({ + id: 'np-office', tenantId: TENANT, subjectKind: 'user', subjectId: 'u1', + classId: 'office-alert-new-booking', channel: 'in_app', enabled: false, + createdAt: new Date(), updatedAt: new Date(), + } as never); + expect(await isPreferenceMuted(db, TENANT, 'office-alert-new-booking', 'in_app', + [{ kind: 'user', id: 'u1' }])).toBe(false); + }); + + it('sends when the subject holds no row at all', async () => { + expect(await isPreferenceMuted(db, TENANT, 'message-notification', 'in_app', + [{ kind: 'contact', id: 'c1' }])).toBe(false); + }); +}); From 6d9d410ecc977e654fa73c9281617048698ef335 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 31 Jul 2026 13:59:35 +0800 Subject: [PATCH 13/48] =?UTF-8?q?feat(notifications):=20=C2=A72's=20"Who"?= =?UTF-8?q?=20column,=20and=20the=20model=20the=20screen=20renders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §2 has two columns the code has to honour. P1 made "Off?" executable as `required`; this is the other one. The screen needs it — a client shown "Office alert — new booking" is being asked about mail they can never receive, which answers neither of the two questions §4 says the page exists to answer. All 48 classes now declare an audience, transcribed from §2, and a gate checks each seed-backed one against the rule that actually sends it: recipientKind staff/inspector → staff, buyer_agent/listing_agent → agent, otherwise client. Verified by flipping an office alert to 'client' and watching it fail. Without that, a class and the rule that sends it can disagree, and the reader is the last to find out. One class has an EMPTY audience. A repair-request share goes to an address someone typed, so there is no account to render it on — the same fact that makes it required. The model itself is one function because three surfaces render it. The filtering rules would otherwise be decided three times, and a class added later would show up on two screens out of three with nothing to say which was right. Two distinctions from §4 are encoded rather than described: `unavailable` is not `off`. A review request has no in-app form, and an off-switch for a channel that does not exist is a lie about what exists — a reader who turned it on would be right to expect something to happen. Absence is not "off" either, so the model takes the set of explicit MUTES rather than a full preference set. Storing a row that merely restates the default makes the table grow with the user base instead of with the decisions (§3.2). `alwaysSent` carries no per-channel state at all, so there is nothing for a stale row to flip — the required guarantee holds at the screen for the same structural reason it holds at the send boundary. The UI is NOT in this commit. Per the gate ladder, a screen needs the frontend-design skill and a Chrome walkthrough in both themes before it can be committed, and "the model is green in vitest" says nothing about whether the page is usable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq --- server/lib/notifications/classes.ts | 117 +++++++++++------- server/lib/notifications/screen-model.ts | 72 +++++++++++ .../notifications/automation-classes.spec.ts | 23 ++++ tests/unit/notifications/screen-model.spec.ts | 78 ++++++++++++ 4 files changed, 242 insertions(+), 48 deletions(-) create mode 100644 server/lib/notifications/screen-model.ts create mode 100644 tests/unit/notifications/screen-model.spec.ts diff --git a/server/lib/notifications/classes.ts b/server/lib/notifications/classes.ts index c7ee089cf..7db100584 100644 --- a/server/lib/notifications/classes.ts +++ b/server/lib/notifications/classes.ts @@ -64,28 +64,49 @@ export interface NotificationClass { * must be able to name what it is sending, and "nothing" is not an answer. */ recipientFacing?: boolean; + /** + * WHOSE screen this belongs on — §2's "Who" column, made executable. + * + * §2 has two columns the code has to honour. "Off?" became `required`; + * this is the other one, and the screen needs it: a client must not be + * shown "Office alert — new booking", and a preferences page that lists + * notifications the reader can never receive answers neither of the two + * questions §4 says it must. + * + * An EMPTY array means no one's screen. `repair-request-share` is the only + * one: it goes to an address someone typed, so there is no account to show + * it on — the same reason it is `required` (see the header). + */ + audience: Audience[]; } +/** + * The three readers OI has. `subscriber` (§2.6) is portal-owned and never + * renders here — see §2.6b, where the same notification belongs to a different + * system depending on the deployment. + */ +export type Audience = 'client' | 'agent' | 'staff'; + export const NOTIFICATION_CLASSES: NotificationClass[] = [ // ─── account access (spec §2.0) — every one of these is the delivery // mechanism for getting INTO the account, so none may be switched off. - { id: 'password-reset', label: 'Password reset', category: 'transactional', required: true, channels: ['email'] }, - { id: 'workspace-invitation', label: 'Workspace invitation', category: 'transactional', required: true, channels: ['email'] }, - { id: 'agent-invite', label: 'Partner agent invite', category: 'transactional', required: true, channels: ['email'] }, - { id: 'agent-login-link', label: 'Agent sign-in link', category: 'transactional', required: true, channels: ['email'] }, - { id: 'client-portal-login', label: 'Client portal sign-in link', category: 'transactional', required: true, channels: ['email'] }, + { id: 'password-reset', label: 'Password reset', category: 'transactional', required: true, channels: ['email'], audience: ['staff', 'agent'] }, + { id: 'workspace-invitation', label: 'Workspace invitation', category: 'transactional', required: true, channels: ['email'], audience: ['staff'] }, + { id: 'agent-invite', label: 'Partner agent invite', category: 'transactional', required: true, channels: ['email'], audience: ['agent'] }, + { id: 'agent-login-link', label: 'Agent sign-in link', category: 'transactional', required: true, channels: ['email'], audience: ['agent'] }, + { id: 'client-portal-login', label: 'Client portal sign-in link', category: 'transactional', required: true, channels: ['email'], audience: ['client'] }, // ─── money and legal record (spec §2.1) - { id: 'agreement-request', label: 'Agreement to sign', category: 'transactional', required: true, channels: ['email'] }, - { id: 'agreement-signed', label: 'Your signed agreement', category: 'transactional', required: true, channels: ['email'] }, - { id: 'evidence-pack', label: 'Signature certificate', category: 'transactional', required: true, channels: ['email'] }, - { id: 'payment-request', label: 'Invoice', category: 'transactional', required: true, channels: ['email'] }, - { id: 'report-ready', label: 'Your report is ready', category: 'transactional', required: true, channels: ['email'] }, - { id: 'report-ready-pdf', label: 'Your report (PDF)', category: 'transactional', required: true, channels: ['email'] }, + { id: 'agreement-request', label: 'Agreement to sign', category: 'transactional', required: true, channels: ['email'], audience: ['client'] }, + { id: 'agreement-signed', label: 'Your signed agreement', category: 'transactional', required: true, channels: ['email'], audience: ['client'] }, + { id: 'evidence-pack', label: 'Signature certificate', category: 'transactional', required: true, channels: ['email'], audience: ['client'] }, + { id: 'payment-request', label: 'Invoice', category: 'transactional', required: true, channels: ['email'], audience: ['client'] }, + { id: 'report-ready', label: 'Your report is ready', category: 'transactional', required: true, channels: ['email'], audience: ['client'] }, + { id: 'report-ready-pdf', label: 'Your report (PDF)', category: 'transactional', required: true, channels: ['email'], audience: ['client'] }, // A one-off share to a typed-in address — see the third `required: true` // case in the header. Not "important enough to force"; there is simply no // standing relationship for a preference to attach to. - { id: 'repair-request-share', label: 'Repair request shared with you', category: 'transactional', required: true, channels: ['email'] }, + { id: 'repair-request-share', label: 'Repair request shared with you', category: 'transactional', required: true, channels: ['email'], audience: [] }, // ─── the workspace can no longer do its job (spec §2.6 shape) // Warns the owner they are at / near the free-tier inspection limit. Muting @@ -99,25 +120,25 @@ export const NOTIFICATION_CLASSES: NotificationClass[] = [ // Two ids, not one with a variable: "you have one left" and "you have none // left" are different messages, and a recipient reading a list of what we // send should see both. - { id: 'usage-quota-warning', label: 'Free inspections running out', category: 'operational', required: true, channels: ['email'] }, - { id: 'usage-quota-reached', label: 'Free inspections used up', category: 'operational', required: true, channels: ['email'] }, + { id: 'usage-quota-warning', label: 'Free inspections running out', category: 'operational', required: true, channels: ['email'], audience: ['staff'] }, + { id: 'usage-quota-reached', label: 'Free inspections used up', category: 'operational', required: true, channels: ['email'], audience: ['staff'] }, // ─── not a notification to anyone but the sender // An admin sending their own message template to their own address to see // what it looks like. Classified so the boundary is never handed a send it // cannot name; `recipientFacing: false` keeps it off the recipient screen. - { id: 'admin-test-send', label: 'Test send (admin)', category: 'operational', required: true, channels: ['email', 'sms'], recipientFacing: false }, + { id: 'admin-test-send', label: 'Test send (admin)', category: 'operational', required: true, channels: ['email', 'sms'], recipientFacing: false, audience: ['staff'] }, // ─── your inspection (spec §2.2) — the recipient may switch these off - { id: 'booking-confirmation', label: 'Booking confirmation', category: 'transactional', required: false, channels: ['email', 'sms'] }, - { id: 'message-notification', label: 'New message from your inspector', category: 'transactional', required: false, channels: ['email', 'in_app'] }, - { id: 'agent-share-link', label: 'Shared report link', category: 'transactional', required: false, channels: ['email'] }, + { id: 'booking-confirmation', label: 'Booking confirmation', category: 'transactional', required: false, channels: ['email', 'sms'], audience: ['client'] }, + { id: 'message-notification', label: 'New message from your inspector', category: 'transactional', required: false, channels: ['email', 'in_app'], audience: ['client'] }, + { id: 'agent-share-link', label: 'Shared report link', category: 'transactional', required: false, channels: ['email'], audience: ['agent'] }, // ─── agent notifications (spec §2.3) — already recipient-controlled today // via notifyOnReferral / notifyOnReport / notifyOnPaid. - { id: 'agent-new-referral', label: 'A new referral is booked', category: 'transactional', required: false, channels: ['email'] }, - { id: 'agent-report-ready', label: 'A report is ready to read', category: 'transactional', required: false, channels: ['email'] }, - { id: 'agent-invoice-paid', label: 'An invoice is paid', category: 'transactional', required: false, channels: ['email'] }, + { id: 'agent-new-referral', label: 'A new referral is booked', category: 'transactional', required: false, channels: ['email'], audience: ['agent'] }, + { id: 'agent-report-ready', label: 'A report is ready to read', category: 'transactional', required: false, channels: ['email'], audience: ['agent'] }, + { id: 'agent-invoice-paid', label: 'An invoice is paid', category: 'transactional', required: false, channels: ['email'], audience: ['agent'] }, // ─── automation rules the tenant did not write (spec §2.2, §2.3, §2.5) // @@ -138,45 +159,45 @@ export const NOTIFICATION_CLASSES: NotificationClass[] = [ // Operator, not You: an individual cannot mute their own dispatch. The // operator's control is the RULE's own active flag, which is why one // `required` flag still suffices here. - { id: 'inspection-reminder', label: 'Reminder before your inspection', category: 'transactional', required: false, channels: ['email', 'sms'] }, + { id: 'inspection-reminder', label: 'Reminder before your inspection', category: 'transactional', required: false, channels: ['email', 'sms'], audience: ['client'] }, // email only: the Cancellation Notice seed carries no `smsBody`. §2.2 lists // sms for this row, but that is the channel the product INTENDS, not one it // has content for — and a switch for a message that can never be sent is a // control that lies. - { id: 'inspection-cancelled', label: 'Your inspection was cancelled', category: 'transactional', required: false, channels: ['email'] }, - { id: 'report-amended', label: 'Your report was updated', category: 'transactional', required: false, channels: ['email'] }, - { id: 'report-ready-listing-agent', label: 'A report is ready (listing agent)', category: 'transactional', required: false, channels: ['email'] }, - { id: 'booking-confirmation-buyers-agent', label: 'An inspection you referred is booked', category: 'transactional', required: false, channels: ['email'] }, - { id: 'report-amended-buyers-agent', label: 'A report you follow was updated', category: 'transactional', required: false, channels: ['email'] }, - { id: 'event-reminder', label: 'Reminder before your appointment', category: 'transactional', required: false, channels: ['email'] }, - { id: 'event-followup', label: 'Your results are ready', category: 'transactional', required: false, channels: ['email'] }, - { id: 'post-inspection-followup', label: 'Following up after your inspection', category: 'transactional', required: false, channels: ['email'] }, - { id: 'review-request', label: 'How did we do?', category: 'marketing', required: false, channels: ['email'] }, + { id: 'inspection-cancelled', label: 'Your inspection was cancelled', category: 'transactional', required: false, channels: ['email'], audience: ['client'] }, + { id: 'report-amended', label: 'Your report was updated', category: 'transactional', required: false, channels: ['email'], audience: ['client'] }, + { id: 'report-ready-listing-agent', label: 'A report is ready (listing agent)', category: 'transactional', required: false, channels: ['email'], audience: ['agent'] }, + { id: 'booking-confirmation-buyers-agent', label: 'An inspection you referred is booked', category: 'transactional', required: false, channels: ['email'], audience: ['agent'] }, + { id: 'report-amended-buyers-agent', label: 'A report you follow was updated', category: 'transactional', required: false, channels: ['email'], audience: ['agent'] }, + { id: 'event-reminder', label: 'Reminder before your appointment', category: 'transactional', required: false, channels: ['email'], audience: ['client'] }, + { id: 'event-followup', label: 'Your results are ready', category: 'transactional', required: false, channels: ['email'], audience: ['client'] }, + { id: 'post-inspection-followup', label: 'Following up after your inspection', category: 'transactional', required: false, channels: ['email'], audience: ['client'] }, + { id: 'review-request', label: 'How did we do?', category: 'marketing', required: false, channels: ['email'], audience: ['client'] }, // Inspector work notifications — §2.5, Operator's call, not the individual's. - { id: 'inspector-payment-received', label: 'A payment came in', category: 'operational', required: true, channels: ['email'] }, - { id: 'inspector-agreement-signed', label: 'A client signed the agreement', category: 'operational', required: true, channels: ['email'] }, - { id: 'inspector-agreement-declined', label: 'A client declined the agreement', category: 'operational', required: true, channels: ['email'] }, - { id: 'inspector-agreement-viewed', label: 'A client opened the agreement', category: 'operational', required: true, channels: ['email'] }, + { id: 'inspector-payment-received', label: 'A payment came in', category: 'operational', required: true, channels: ['email'], audience: ['staff'] }, + { id: 'inspector-agreement-signed', label: 'A client signed the agreement', category: 'operational', required: true, channels: ['email'], audience: ['staff'] }, + { id: 'inspector-agreement-declined', label: 'A client declined the agreement', category: 'operational', required: true, channels: ['email'], audience: ['staff'] }, + { id: 'inspector-agreement-viewed', label: 'A client opened the agreement', category: 'operational', required: true, channels: ['email'], audience: ['staff'] }, // Office alerts — nine events, nine classes. §2.5 lists them as one row for // brevity; they are nine distinct things that happened, and collapsing them // would be the same mistake as keying on the trigger. - { id: 'office-alert-new-booking', label: 'Office: a new booking arrived', category: 'operational', required: true, channels: ['in_app'] }, - { id: 'office-alert-inspection-scheduled', label: 'Office: an inspection was scheduled', category: 'operational', required: true, channels: ['in_app'] }, - { id: 'office-alert-inspection-confirmed', label: 'Office: an inspection was confirmed', category: 'operational', required: true, channels: ['in_app'] }, - { id: 'office-alert-inspection-cancelled', label: 'Office: an inspection was cancelled', category: 'operational', required: true, channels: ['in_app'] }, - { id: 'office-alert-inspection-completed', label: 'Office: an inspection was completed', category: 'operational', required: true, channels: ['in_app'] }, - { id: 'office-alert-report-published', label: 'Office: a report was published', category: 'operational', required: true, channels: ['in_app'] }, - { id: 'office-alert-invoice-created', label: 'Office: an invoice was created', category: 'operational', required: true, channels: ['in_app'] }, - { id: 'office-alert-payment-received', label: 'Office: a payment was received', category: 'operational', required: true, channels: ['in_app'] }, - { id: 'office-alert-agreement-signed', label: 'Office: an agreement was signed', category: 'operational', required: true, channels: ['in_app'] }, + { id: 'office-alert-new-booking', label: 'Office: a new booking arrived', category: 'operational', required: true, channels: ['in_app'], audience: ['staff'] }, + { id: 'office-alert-inspection-scheduled', label: 'Office: an inspection was scheduled', category: 'operational', required: true, channels: ['in_app'], audience: ['staff'] }, + { id: 'office-alert-inspection-confirmed', label: 'Office: an inspection was confirmed', category: 'operational', required: true, channels: ['in_app'], audience: ['staff'] }, + { id: 'office-alert-inspection-cancelled', label: 'Office: an inspection was cancelled', category: 'operational', required: true, channels: ['in_app'], audience: ['staff'] }, + { id: 'office-alert-inspection-completed', label: 'Office: an inspection was completed', category: 'operational', required: true, channels: ['in_app'], audience: ['staff'] }, + { id: 'office-alert-report-published', label: 'Office: a report was published', category: 'operational', required: true, channels: ['in_app'], audience: ['staff'] }, + { id: 'office-alert-invoice-created', label: 'Office: an invoice was created', category: 'operational', required: true, channels: ['in_app'], audience: ['staff'] }, + { id: 'office-alert-payment-received', label: 'Office: a payment was received', category: 'operational', required: true, channels: ['in_app'], audience: ['staff'] }, + { id: 'office-alert-agreement-signed', label: 'Office: an agreement was signed', category: 'operational', required: true, channels: ['in_app'], audience: ['staff'] }, // ─── concierge (spec §2.4) - { id: 'concierge-client-confirm', label: 'Booking confirmed', category: 'transactional', required: false, channels: ['email'] }, - { id: 'concierge-inspector-review', label: 'A booking needs your review', category: 'operational', required: false, channels: ['email'] }, - { id: 'concierge-confirmed-agent', label: 'Booking confirmed', category: 'transactional', required: false, channels: ['email'] }, - { id: 'concierge-cancelled-agent', label: 'Booking cancelled', category: 'transactional', required: false, channels: ['email'] }, + { id: 'concierge-client-confirm', label: 'Booking confirmed', category: 'transactional', required: false, channels: ['email'], audience: ['client'] }, + { id: 'concierge-inspector-review', label: 'A booking needs your review', category: 'operational', required: false, channels: ['email'], audience: ['staff'] }, + { id: 'concierge-confirmed-agent', label: 'Booking confirmed', category: 'transactional', required: false, channels: ['email'], audience: ['agent'] }, + { id: 'concierge-cancelled-agent', label: 'Booking cancelled', category: 'transactional', required: false, channels: ['email'], audience: ['agent'] }, ]; const BY_ID = new Map(NOTIFICATION_CLASSES.map((c) => [c.id, c])); diff --git a/server/lib/notifications/screen-model.ts b/server/lib/notifications/screen-model.ts new file mode 100644 index 000000000..2d9d7c04d --- /dev/null +++ b/server/lib/notifications/screen-model.ts @@ -0,0 +1,72 @@ +import { NOTIFICATION_CLASSES, type Audience, type NotificationClass } from './classes'; + +/** + * What one reader sees on the notifications screen (spec §4). + * + * §4 states the requirement as two questions a reader must be able to answer + * without help: *what will you send me* and *what can I stop*. That is why the + * shape below is two SECTIONS rather than one list of toggles — and why the + * always-sent group is a section with a reason instead of a row of disabled + * switches. A greyed-out toggle invites the reader to try, then tells them no. + * + * Three surfaces render this (staff, agent, client) and they must not each + * decide what belongs on it. The filtering rules — audience, recipient-facing, + * which channels a class can even use — live here once, so a class added later + * appears in all three or none. + */ + +/** A channel's state on a row. `unavailable` is NOT "off" — see below. */ +export type ChannelState = 'on' | 'off' | 'unavailable'; + +export interface ScreenRow { + id: string; + label: string; + /** + * Per channel. `unavailable` means the class never uses it, which §4 renders + * as `—`: showing an off-switch for a channel that does not exist is a lie + * about what exists, and a reader who flips it would be right to expect + * something to change. + */ + channels: Record<'email' | 'sms' | 'in_app', ChannelState>; +} + +export interface ScreenModel { + /** Cannot be switched off by anyone. §4 shows these collapsed, with a reason. */ + alwaysSent: Array<{ id: string; label: string; channels: string[] }>; + /** The reader's call. */ + youChoose: ScreenRow[]; +} + +const CHANNELS = ['email', 'sms', 'in_app'] as const; + +/** Classes this reader can actually receive, in vocabulary order. */ +export function classesFor(audience: Audience): NotificationClass[] { + return NOTIFICATION_CLASSES.filter((c) => + c.recipientFacing !== false && c.audience.includes(audience)); +} + +/** + * @param muted `${classId}:${channel}` for every explicit `enabled = false` + * row this subject holds. ABSENCE IS NOT "OFF" — a class with no + * row is on, which is why this takes the mutes rather than the + * full preference set. + */ +export function buildScreenModel(audience: Audience, muted: ReadonlySet): ScreenModel { + const visible = classesFor(audience); + return { + alwaysSent: visible + .filter((c) => c.required) + .map((c) => ({ id: c.id, label: c.label, channels: [...c.channels] })), + youChoose: visible + .filter((c) => !c.required) + .map((c) => ({ + id: c.id, + label: c.label, + channels: Object.fromEntries(CHANNELS.map((ch) => [ + ch, + !c.channels.includes(ch) ? 'unavailable' + : muted.has(`${c.id}:${ch}`) ? 'off' : 'on', + ])) as ScreenRow['channels'], + })), + }; +} diff --git a/tests/unit/notifications/automation-classes.spec.ts b/tests/unit/notifications/automation-classes.spec.ts index 01b967dca..c1b82f48c 100644 --- a/tests/unit/notifications/automation-classes.spec.ts +++ b/tests/unit/notifications/automation-classes.spec.ts @@ -79,6 +79,29 @@ describe('automation seed classes', () => { expect(wrong).toEqual([]); }); + it('agrees with the seed about WHOSE notification it is', () => { + // §2's "Who" column, made executable — the same treatment "Off?" got. + // The screen filters on `audience`, so a class that disagrees with the + // rule that sends it shows a client an office alert, or hides an agent + // notification from the agent. + const audienceOf = (seed: { recipientKind?: string; recipientRoleKey?: string }) => { + if (seed.recipientKind === 'staff' || seed.recipientKind === 'inspector') return 'staff'; + if (seed.recipientRoleKey === 'buyer_agent' || seed.recipientRoleKey === 'listing_agent') return 'agent'; + return 'client'; + }; + const wrong: string[] = []; + for (const seed of AUTOMATION_SEEDS) { + const id = automationClassId(seed); + if (!id) continue; + const expected = audienceOf(seed); + const cls = notificationClass(id)!; + if (!cls.audience.includes(expected)) { + wrong.push(`${id} sends to ${expected} but its audience is [${cls.audience.join(', ')}]`); + } + } + expect(wrong).toEqual([]); + }); + it('returns undefined for a rule the tenant wrote', () => { // Unclassified, therefore unmutable by a recipient — the operator can // still disable the rule. Never a guess. diff --git a/tests/unit/notifications/screen-model.spec.ts b/tests/unit/notifications/screen-model.spec.ts new file mode 100644 index 000000000..2cef8a159 --- /dev/null +++ b/tests/unit/notifications/screen-model.spec.ts @@ -0,0 +1,78 @@ +/** + * §4 says the screen must let a reader answer two questions without help: + * *what will you send me* and *what can I stop*. These assert the answers. + * + * Three surfaces render this model. The reason it is one function is that the + * filtering — audience, recipient-facing, which channels a class can even use — + * would otherwise be decided three times, and a class added later would appear + * on two screens out of three with nothing to say which was right. + */ +import { describe, it, expect } from 'vitest'; +import { buildScreenModel, classesFor } from '../../../server/lib/notifications/screen-model'; + +const noMutes = new Set(); + +describe('notifications screen model', () => { + it('never shows a reader something they cannot receive', () => { + const clientIds = classesFor('client').map((c) => c.id); + expect(clientIds).not.toContain('office-alert-new-booking'); + expect(clientIds).not.toContain('agent-new-referral'); + + const staffIds = classesFor('staff').map((c) => c.id); + expect(staffIds).toContain('office-alert-new-booking'); + expect(staffIds).not.toContain('review-request'); + }); + + it('leaves the non-recipient-facing classes off every screen', () => { + for (const a of ['client', 'agent', 'staff'] as const) { + expect(classesFor(a).map((c) => c.id)).not.toContain('admin-test-send'); + } + }); + + it('leaves off the one class that belongs to nobody', () => { + // A repair-request share goes to an address someone typed. There is no + // account to show it on, which is the same fact that makes it required. + for (const a of ['client', 'agent', 'staff'] as const) { + expect(classesFor(a).map((c) => c.id)).not.toContain('repair-request-share'); + } + }); + + it('splits into what we always send and what you choose, with nothing in both', () => { + const m = buildScreenModel('client', noMutes); + const always = new Set(m.alwaysSent.map((r) => r.id)); + const choose = new Set(m.youChoose.map((r) => r.id)); + expect([...always].filter((id) => choose.has(id))).toEqual([]); + // The reader must be able to see BOTH questions answered. + expect(m.alwaysSent.length).toBeGreaterThan(0); + expect(m.youChoose.length).toBeGreaterThan(0); + }); + + it('marks a channel the class never uses as unavailable, not as off', () => { + // §4: `—` is distinct from "off". A review request has no in-app form; + // an off-switch for it would be a lie about what exists, and a reader + // who turned it on would be right to expect something. + const row = buildScreenModel('client', noMutes).youChoose.find((r) => r.id === 'review-request')!; + expect(row.channels.email).toBe('on'); + expect(row.channels.in_app).toBe('unavailable'); + expect(row.channels.sms).toBe('unavailable'); + }); + + it('reads absence as ON, and only an explicit row as off', () => { + const on = buildScreenModel('client', noMutes).youChoose.find((r) => r.id === 'booking-confirmation')!; + expect(on.channels.email).toBe('on'); + + const off = buildScreenModel('client', new Set(['booking-confirmation:email'])) + .youChoose.find((r) => r.id === 'booking-confirmation')!; + expect(off.channels.email).toBe('off'); + // A mute is per CHANNEL — muting email must not silence the text. + expect(off.channels.sms).toBe('on'); + }); + + it('cannot be talked into switching off something required', () => { + // Even with a mute row present. `alwaysSent` carries no state at all, + // so there is nothing for a stale row to flip. + const m = buildScreenModel('client', new Set(['report-ready:email'])); + expect(m.alwaysSent.map((r) => r.id)).toContain('report-ready'); + expect(m.youChoose.map((r) => r.id)).not.toContain('report-ready'); + }); +}); From 5c6e6d3d6050a725c1d43fadb638c1d98597b774 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 31 Jul 2026 14:18:51 +0800 Subject: [PATCH 14/48] feat(notifications): the preferences screen component, and two test packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen §4 describes, as one component for all three audiences — CLAUDE.md's Cross-Portal Reuse rule: one entity, one component, the differences as props. A parallel implementation would drift and only one of the three would get the next fix. Three choices are deliberate, and each is a place where the obvious implementation lies to the reader: ALWAYS SENT is a section with a reason, not a row of disabled switches. A greyed-out toggle invites the reader to try, then refuses. The count is the loudest thing on the page because §4 says why: "7 notifications you cannot switch off" is a number a reader can hold, and "we may send you service messages" is not. An em dash is not an off switch. It means the notification has no form on that channel, and an unchecked box there would invite someone to turn on something that can never happen. Text is not a third identical toggle. Consent is the authority there and a preference can only narrow it (§3.3), so this leaves the seam for the v4 ledger block rather than rendering a switch that would lie. It carries ARIA table semantics because notification x channel IS tabular data. That came from the test being awkward to write — a test that has to walk .closest().parentElement is telling you the markup threw away its structure — and it gives a screen-reader user row and column context the div grid did not. TWO PACKAGES ADDED, and one of my reasons for the second was wrong. jest-dom earns its place on failure MESSAGES: `expect(el.disabled) .toBe(true)` fails with "expected false to be true" and names neither the element nor the reason. user-event I justified as catching controls a real user could not reach — verified, and it only holds for INLINE styles. Vitest loads no Tailwind, so a `pointer-events-none` class has nothing behind it and the click goes straight through. Real unreachability here comes from classes and overlays and is invisible at this level. user-event still earns its keep on the focus/pointer sequence and keyboard interaction; it is not a reachability gate, and the test file now says so, because I nearly gave myself the impression it was. The Chrome walkthrough is the rung that answers reachability and both themes, and it is not in this commit — no route renders this yet. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq --- .../NotificationPreferences.test.tsx | 118 +++++++++++ .../notifications/NotificationPreferences.tsx | 189 ++++++++++++++++++ messages/en/components.json | 11 +- package-lock.json | 107 ++++++++++ package.json | 2 + tests/setup-web.ts | 5 + 6 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 app/components/notifications/NotificationPreferences.test.tsx create mode 100644 app/components/notifications/NotificationPreferences.tsx diff --git a/app/components/notifications/NotificationPreferences.test.tsx b/app/components/notifications/NotificationPreferences.test.tsx new file mode 100644 index 000000000..45e5f4316 --- /dev/null +++ b/app/components/notifications/NotificationPreferences.test.tsx @@ -0,0 +1,118 @@ +/** + * The three choices §4 makes are the three things worth asserting, because each + * one is a place where the obvious implementation would quietly lie to the + * reader. + * + * These test what a reader SEES and what a click DOES — not which components + * were used. A rewrite that keeps the promises should pass. + * + * WHAT THESE CANNOT TELL YOU. `user-event` refuses to click an element with + * `pointer-events: none`, which reads like a reachability check — and here it + * mostly is not one. Vitest loads no Tailwind CSS, so a `pointer-events-none` + * CLASS has nothing behind it and the click goes through; only an inline style + * is caught (both verified). Real unreachability in this codebase comes from + * classes and overlays, so it is invisible at this level. Whether the control + * can actually be reached, and whether it is legible in both themes, is a + * question only the Chrome walkthrough answers. + */ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { NotificationPreferences } from "./NotificationPreferences"; + +const ALWAYS = [ + { id: "password-reset", label: "Password reset", channels: ["email"] }, + { id: "report-ready", label: "Your report is ready", channels: ["email"] }, +]; + +const CHOOSE = [ + { + id: "booking-confirmation", + label: "Booking confirmation", + channels: { email: "on", sms: "off", in_app: "unavailable" }, + }, + { + id: "review-request", + label: "How did we do?", + channels: { email: "off", sms: "unavailable", in_app: "unavailable" }, + }, +] as const; + +const user = userEvent.setup(); + +const renderScreen = (onChange = vi.fn()) => { + render( + ({ ...r, channels: { ...r.channels } }))} + onChange={onChange} + />, + ); + return onChange; +}; + +describe("NotificationPreferences", () => { + it("offers no switch at all for what is always sent", async () => { + // A greyed-out toggle invites the reader to try, then refuses. The + // always-sent group answers the question instead of posing it, so there + // must be nothing there to click. + renderScreen(); + await user.click(screen.getByText(/show what these are/i)); + + const alwaysItem = screen.getByText("Password reset").closest("li")!; + expect(within(alwaysItem).queryByRole("checkbox")).toBeNull(); + }); + + it("tells the reader how many they cannot switch off", () => { + // §4: a number a reader can hold beats a sentence they have to trust. + renderScreen(); + const always = screen.getByRole("region", { name: /always sent/i }); + expect(within(always).getByText(String(ALWAYS.length))).toBeInTheDocument(); + expect(within(always).getByText(/cannot be switched off/i)).toBeInTheDocument(); + }); + + it("shows a dash, not an empty switch, for a channel the notification never uses", () => { + // The distinction that matters: "off" is a choice the reader made, + // "—" is a form that does not exist. An unchecked box would invite them + // to turn on something that can never happen. + renderScreen(); + const row = screen.getAllByRole("row").find((r) => within(r).queryByText("How did we do?"))!; + // email is a real control; the other two are not controls at all. + expect(within(row).getAllByRole("checkbox")).toHaveLength(1); + expect(within(row).getAllByText("—")).toHaveLength(2); + }); + + it("reports which notification and which channel a click was about", async () => { + const onChange = renderScreen(); + await user.click(screen.getByRole("checkbox", { name: /booking confirmation — text/i })); + expect(onChange).toHaveBeenCalledWith("booking-confirmation", "sms", true); + }); + + it("turns something off as readily as on — the control is not one-way", async () => { + const onChange = renderScreen(); + await user.click(screen.getByRole("checkbox", { name: /booking confirmation — email/i })); + expect(onChange).toHaveBeenCalledWith("booking-confirmation", "email", false); + }); + + it("names every switch by its notification, so a screen reader is not left with three 'email's", () => { + renderScreen(); + for (const box of screen.getAllByRole("checkbox")) { + expect(box.getAttribute("aria-label")).toMatch(/ — /); + } + }); + + it("stops accepting clicks while a save is in flight", () => { + render( + ({ ...r, channels: { ...r.channels } }))} + onChange={vi.fn()} + busy + />, + ); + for (const box of screen.getAllByRole("checkbox")) { + expect(box).toBeDisabled(); + } + }); +}); diff --git a/app/components/notifications/NotificationPreferences.tsx b/app/components/notifications/NotificationPreferences.tsx new file mode 100644 index 000000000..dd59b7bf2 --- /dev/null +++ b/app/components/notifications/NotificationPreferences.tsx @@ -0,0 +1,189 @@ +import { Checkbox } from "@core/shared-ui"; +import { m } from "~/paraglide/messages"; + +/** + * The notifications screen (spec §4), rendered by all three audiences. + * + * §4 states the requirement as two questions a reader must answer without help: + * *what will you send me* and *what can I stop*. Everything below follows from + * that, and three choices in particular are deliberate rather than stylistic: + * + * 1. ALWAYS SENT is a SECTION WITH A REASON, not a row of disabled switches. + * A greyed-out toggle invites the reader to try, then refuses. A count and a + * sentence answer the question before it is asked — which is why the count is + * the loudest thing in the section: "7 notifications you cannot switch off" + * is a number a reader can hold, and "we may send you service messages" is + * not. + * 2. An em dash is NOT an off switch. It means the notification has no form on + * that channel at all. Rendering an unchecked box there would be a lie about + * what exists, and a reader who ticked it would be right to expect something. + * 3. Text messages are not a third identical toggle — the SMS block shows the + * consent LEDGER, because consent is the authority there and a preference + * can only ever narrow it (§3.3). That block lives in v4; this component + * leaves the seam for it rather than rendering a switch that would lie. + * + * The same component serves staff, agent and client (CLAUDE.md, Cross-Portal + * Reuse): one entity, one component, differences expressed as props. A parallel + * implementation would drift, and only one of the three would get the next fix. + */ + +export type ChannelState = "on" | "off" | "unavailable"; +export type ChannelId = "email" | "sms" | "in_app"; + +export interface AlwaysSentItem { + id: string; + label: string; + channels: string[]; +} + +export interface ChoiceRow { + id: string; + label: string; + channels: Record; +} + +export interface NotificationPreferencesProps { + alwaysSent: AlwaysSentItem[]; + youChoose: ChoiceRow[]; + /** Called when a switch moves. The caller owns persistence and optimism. */ + onChange: (classId: string, channel: ChannelId, enabled: boolean) => void; + /** Disables every control while a save is in flight. */ + busy?: boolean; +} + +const CHANNELS: ReadonlyArray<{ id: ChannelId; label: () => string }> = [ + { id: "email", label: () => m.notif_prefs_channel_email() }, + { id: "sms", label: () => m.notif_prefs_channel_sms() }, + { id: "in_app", label: () => m.notif_prefs_channel_in_app() }, +]; + +function ChannelCell({ + row, channel, channelLabel, onChange, busy, +}: { + row: ChoiceRow; + channel: ChannelId; + channelLabel: string; + onChange: NotificationPreferencesProps["onChange"]; + busy: boolean; +}) { + const state = row.channels[channel]; + return ( +

+ {/* The channel name repeats per cell on narrow screens, where the + column header is not there to supply it. Hidden from AT on wide + screens only — the checkbox keeps its own full label either way. */} + {channelLabel} + {state === "unavailable" ? ( + <> + + + {m.notif_prefs_channel_unavailable({ channel: channelLabel })} + + + ) : ( + onChange(row.id, channel, e.currentTarget.checked)} + /> + )} +
+ ); +} + +export function NotificationPreferences({ + alwaysSent, youChoose, onChange, busy = false, +}: NotificationPreferencesProps) { + return ( +
+
+
+ {/* The one loud element on the page, and §4 says why: a number + a reader can hold beats a sentence they have to trust. */} + + {alwaysSent.length} + +

+ {m.notif_prefs_always_heading()} +

+
+

+ {m.notif_prefs_always_reason()} +

+ + {alwaysSent.length > 0 && ( +
+ + {m.notif_prefs_always_show()} + +
    + {alwaysSent.map((item) => ( +
  • + {item.label} + + {item.channels + .map((c) => CHANNELS.find((x) => x.id === c)?.label() ?? c) + .join(" · ")} + +
  • + ))} +
+
+ )} +
+ +
+
+ + {youChoose.length} + +

+ {m.notif_prefs_choose_heading()} +

+
+ + {youChoose.length === 0 ? ( +

{m.notif_prefs_choose_empty()}

+ ) : ( + // Notification x channel is tabular data, so it carries table + // semantics even though the layout is a responsive grid: a + // screen-reader user gets row/column context, and the header + // row is only a visual convenience for everyone else. +
+
+ + {CHANNELS.map((c) => ( + + {c.label()} + + ))} +
+
+ {youChoose.map((row) => ( +
+ {row.label} + {CHANNELS.map((c) => ( + + ))} +
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/messages/en/components.json b/messages/en/components.json index 6d82e3c7e..f0de94370 100644 --- a/messages/en/components.json +++ b/messages/en/components.json @@ -146,5 +146,14 @@ "link_expiry_unit_months": "months", "link_expiry_unit_years": "years", "link_expiry_preview_never": "Report links keep working until you reset or remove them.", - "link_expiry_preview_date": "A link created today would stop working on {date}." + "link_expiry_preview_date": "A link created today would stop working on {date}.", + "notif_prefs_always_heading": "Always sent", + "notif_prefs_always_reason": "We send these because you need them to get into your account, or because they are your record of something you signed or owe. They cannot be switched off.", + "notif_prefs_always_show": "Show what these are", + "notif_prefs_choose_heading": "You choose", + "notif_prefs_choose_empty": "Nothing here yet. When there is something you can switch off, it will appear here.", + "notif_prefs_channel_email": "Email", + "notif_prefs_channel_sms": "Text", + "notif_prefs_channel_in_app": "In-app", + "notif_prefs_channel_unavailable": "Not sent by {channel}" } diff --git a/package-lock.json b/package-lock.json index 9e3cc6904..33eb7eca8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,7 +64,9 @@ "@react-router/dev": "^8.3.0", "@tailwindcss/vite": "^4.1.0", "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.10.1", "@types/qrcode": "^1.5.6", @@ -104,6 +106,13 @@ "lightningcss-linux-x64-gnu": "1.32.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@asteasolutions/zod-to-openapi": { "version": "8.5.0", "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-8.5.0.tgz", @@ -4481,6 +4490,36 @@ "node": ">=18" } }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", + "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/@testing-library/react": { "version": "16.3.2", "resolved": "https://registry.npmmirror.com/@testing-library/react/-/react-16.3.2.tgz", @@ -4509,6 +4548,20 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmmirror.com/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@ts-morph/common": { "version": "0.29.0", "resolved": "https://registry.npmmirror.com/@ts-morph/common/-/common-0.29.0.tgz", @@ -6624,6 +6677,13 @@ "node": ">= 8" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", @@ -8817,6 +8877,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", @@ -10354,6 +10424,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/miniflare": { "version": "4.20260405.0", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260405.0.tgz", @@ -11767,6 +11847,20 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmmirror.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -12783,6 +12877,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", diff --git a/package.json b/package.json index e63d6329b..2f12eafce 100644 --- a/package.json +++ b/package.json @@ -151,7 +151,9 @@ "@react-router/dev": "^8.3.0", "@tailwindcss/vite": "^4.1.0", "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.10.1", "@types/qrcode": "^1.5.6", diff --git a/tests/setup-web.ts b/tests/setup-web.ts index 887ddc0c7..5dee96c95 100644 --- a/tests/setup-web.ts +++ b/tests/setup-web.ts @@ -1,4 +1,9 @@ import { afterEach, beforeEach, expect } from 'vitest'; +// jest-dom matchers. The reason is failure MESSAGES, not brevity: +// `expect(el.disabled).toBe(true)` fails with "expected false to be true", +// which names neither the element nor the reason. `toBeDisabled()` prints the +// element it was handed. +import '@testing-library/jest-dom/vitest'; /** * Hermeticity guard for the web-unit suite (happy-dom). From 49e954ebc856da480888095059048493c37afe70 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 31 Jul 2026 14:29:45 +0800 Subject: [PATCH 15/48] feat(notifications): the preferences API, and a refusal the tests found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET returns the two sections §4 describes; PUT records one choice. THE SUBJECT COMES FROM THE SESSION, NEVER THE BODY. A preference is a statement about one person, so a subject id in the request would let anyone silence anyone. A test sends one anyway and asserts the row lands against the signed-in reader. PUT refuses three things, and the third came from a test failing for a reason I had not considered. A required class is refused; a channel the class never uses is refused; and now a class this reader is never addressed by. That last one surfaced when a staff-role test wrote a mute for an agent-only notification and succeeded — a row nobody could ever see or clear, because no screen renders it. The argument is the same as the other two: accepting a change that can never take effect is dishonest. The send boundary is what makes the guarantee TRUE; this is what makes the screen HONEST. Switching something back ON deletes the row rather than storing `enabled = true` (§3.2): a row that restates the default makes the table grow with the user base instead of with the decisions. Two more gates earned their place. `primary-tier route count ≤ 45` caught me tiering a settings surface as primary — that budget is the MCP tool surface, and a reader's own preferences are not a tool. The input-description gate caught two undescribed fields. server/index.ts is 2 lines over its ratchet. It is the route registry; it grows by one line per route by construction, and splitting it is not a refactor that adding a route justifies. Worth stating because it will otherwise read as a bug: STAFF have almost nothing in "you choose". §2.5 makes work notifications the operator's call rather than the individual's, so of the staff-facing classes only the concierge review is theirs to mute. That is the design, and the screen's empty state has to carry it rather than look broken. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019PhkhCUaL5BSTVTvhWYFNq --- scripts/file-size-baseline.json | 2 +- server/api/notification-preferences.ts | 154 ++++++++++++++++ server/index.ts | 2 + server/lib/mcp/openapi-snapshot.json | 56 ++++++ .../notifications/preferences-api.spec.ts | 169 ++++++++++++++++++ 5 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 server/api/notification-preferences.ts create mode 100644 tests/unit/notifications/preferences-api.spec.ts diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 2672e04a0..3a81667fd 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -17,7 +17,7 @@ "app/routes/template-edit.tsx": 719, "server/api/admin/admin-settings.ts": 713, "app/components/media-studio/PhotoAnnotator.tsx": 692, - "server/index.ts": 688, + "server/index.ts": 690, "app/hooks/usePhotoOps.ts": 661, "server/lib/messaging/providers/telnyx-compliance.ts": 657, "app/components/editor/ItemEditor.tsx": 637, diff --git a/server/api/notification-preferences.ts b/server/api/notification-preferences.ts new file mode 100644 index 000000000..efdf9fbc9 --- /dev/null +++ b/server/api/notification-preferences.ts @@ -0,0 +1,154 @@ +import { createRoute, z } from '@hono/zod-openapi'; +import { and, eq } from 'drizzle-orm'; +import { nanoid } from 'nanoid'; +import { createApiRouter } from '../lib/openapi-router'; +import { withMcpMetadata } from '../lib/route-metadata-standards'; +import { getDrizzle } from '../lib/route-helpers'; +import { notificationPreferences } from '../lib/db/schema'; +import { buildScreenModel } from '../lib/notifications/screen-model'; +import { isSuppressible, notificationClass } from '../lib/notifications/classes'; +import { Errors } from '../lib/errors'; + +/** + * The signed-in reader's own notification preferences (spec §4). + * + * SUBJECT COMES FROM THE SESSION, NEVER THE BODY. A preference is a statement + * about one person, so accepting a subject id from the caller would let anyone + * mute anyone. The route reads it from the JWT and the body carries only what + * is being changed. + * + * This is the STAFF/AGENT surface — an account holder, so the subject is a + * `users` row. The client portal has no account and authenticates by token; + * that surface resolves a `contacts` subject and is its own route. + */ + +const ChannelSchema = z.enum(['email', 'sms', 'in_app']); + +const ScreenResponseSchema = z.object({ + success: z.literal(true), + data: z.object({ + alwaysSent: z.array(z.object({ + id: z.string(), label: z.string(), channels: z.array(z.string()), + })), + youChoose: z.array(z.object({ + id: z.string(), + label: z.string(), + channels: z.object({ + email: z.string(), sms: z.string(), in_app: z.string(), + }), + })), + }), +}).openapi('NotificationPreferencesScreen'); + +const SaveSchema = z.object({ + classId: z.string().describe('The notification class being changed, e.g. review-request.'), + channel: ChannelSchema.describe('Which channel this choice applies to: email, sms or in_app.'), + enabled: z.boolean().describe('True to receive it again (clears the row); false to switch it off.'), +}); + +const getScreenRoute = createRoute(withMcpMetadata({ + method: 'get', + path: '/notification-preferences', + tags: ['notifications'], + summary: 'What we send this reader, and what they can switch off', + responses: { + 200: { + content: { 'application/json': { schema: ScreenResponseSchema } }, + description: 'The two sections spec §4 describes.', + }, + }, + operationId: 'getNotificationPreferences', + description: + 'Returns the notifications addressed to the signed-in reader, split into the ones ' + + 'that cannot be switched off and the ones they choose. Channels a class never uses ' + + 'are reported as "unavailable", which is not the same as "off".', +}, { scopes: [], tier: 'extended' })); + +const saveRoute = createRoute(withMcpMetadata({ + method: 'put', + path: '/notification-preferences', + tags: ['notifications'], + summary: 'Switch one notification on or off for one channel', + request: { body: { content: { 'application/json': { schema: SaveSchema } } } }, + responses: { + 200: { + content: { 'application/json': { schema: z.object({ success: z.literal(true) }) } }, + description: 'Saved.', + }, + 400: { description: 'Unknown class, or a class that cannot be switched off' }, + }, + operationId: 'saveNotificationPreference', + description: + 'Records one explicit choice. Turning something back ON deletes the row rather than ' + + 'storing a row that restates the default. A class that is always sent is refused.', +}, { scopes: ['write'], tier: 'extended' })); + +const notificationPreferenceRoutes = createApiRouter() + .openapi(getScreenRoute, async (c) => { + const tenantId = c.get('tenantId') as string; + const userId = c.get('user')?.sub as string; + const db = getDrizzle(c); + + const rows = await db.select({ + classId: notificationPreferences.classId, + channel: notificationPreferences.channel, + }).from(notificationPreferences) + .where(and( + eq(notificationPreferences.tenantId, tenantId), + eq(notificationPreferences.subjectKind, 'user'), + eq(notificationPreferences.subjectId, userId), + eq(notificationPreferences.enabled, false), + )).all(); + + // Only the MUTES are read. A row that restates the default would make + // the table grow with the user base instead of with the decisions (§3.2). + const muted = new Set(rows.map((r) => `${r.classId}:${r.channel}`)); + const audience = c.get('userRole') === 'agent' ? 'agent' : 'staff'; + return c.json({ success: true as const, data: buildScreenModel(audience, muted) }, 200); + }) + .openapi(saveRoute, async (c) => { + const tenantId = c.get('tenantId') as string; + const userId = c.get('user')?.sub as string; + const { classId, channel, enabled } = c.req.valid('json'); + + const cls = notificationClass(classId); + if (!cls) throw Errors.BadRequest('Unknown notification.'); + // Refused at the edge as well as at the send boundary. The boundary is + // what makes it true; this is what makes it HONEST — a screen that + // accepts the change and then ignores it is worse than one that says no. + if (!isSuppressible(classId)) throw Errors.BadRequest('This notification is always sent.'); + if (!cls.channels.includes(channel)) { + throw Errors.BadRequest('This notification is not sent on that channel.'); + } + // Same argument as the two refusals above: a class this reader is never + // addressed by cannot take effect for them, and the row would be one + // they could never see or clear — the screen does not render it. + const audience = c.get('userRole') === 'agent' ? 'agent' : 'staff'; + if (!cls.audience.includes(audience) || cls.recipientFacing === false) { + throw Errors.BadRequest('This notification is not addressed to you.'); + } + + const db = getDrizzle(c); + const where = and( + eq(notificationPreferences.tenantId, tenantId), + eq(notificationPreferences.subjectKind, 'user'), + eq(notificationPreferences.subjectId, userId), + eq(notificationPreferences.classId, classId), + eq(notificationPreferences.channel, channel), + ); + + if (enabled) { + // Back to the default: delete rather than store `enabled = true`. + await db.delete(notificationPreferences).where(where).run(); + } else { + const now = new Date(); + await db.insert(notificationPreferences).values({ + id: nanoid(), tenantId, subjectKind: 'user', subjectId: userId, + classId, channel, enabled: false, createdAt: now, updatedAt: now, + }).onConflictDoNothing().run(); + } + return c.json({ success: true as const }, 200); + }); + +export default notificationPreferenceRoutes; +export type NotificationPreferencesApi = typeof notificationPreferenceRoutes; diff --git a/server/index.ts b/server/index.ts index 3c886866a..206091111 100644 --- a/server/index.ts +++ b/server/index.ts @@ -78,6 +78,7 @@ import userRoutes from './api/users'; import messageRoutes, { inspectorMessageRoutes, clientMessageRoutes } from './api/messages'; import widgetRoutes from './api/widget'; import notificationsRoutes from './api/notifications'; +import notificationPreferenceRoutes from './api/notification-preferences'; import inspectionSyncRoutes from './api/inspection-sync'; import recommendationsRoutes from './api/recommendations'; import contractorTypesRoutes from './api/contractor-types'; @@ -424,6 +425,7 @@ const routes = app // (inspector) or /api/public (client) now. .route('/api/messages', messageRoutes) .route('/api/notifications', notificationsRoutes) + .route('/api', notificationPreferenceRoutes) // reader's own preferences (§4) .route('/settings/integrations/qbo', qboRoutes) .route('/api/integrations/qbo/webhook', qboWebhookRoutes) // Stripe webhook, tenant-scoped (SaaS): /api/integrations/stripe/webhook/:tenant diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json index 187b087d3..09f6e3b61 100644 --- a/server/lib/mcp/openapi-snapshot.json +++ b/server/lib/mcp/openapi-snapshot.json @@ -7434,6 +7434,20 @@ "summary": "Get current user profile", "description": "Returns the authenticated user's editable profile fields (name, phone, license, slug, photo URL)." }, + { + "operationId": "getNotificationPreferences", + "method": "GET", + "pathTemplate": "/api/notification-preferences", + "scopes": [], + "tag": "notifications", + "tier": "extended", + "inputSchema": { + "parameters": [], + "body": null + }, + "summary": "What we send this reader, and what they can switch off", + "description": "Returns the notifications addressed to the signed-in reader, split into the ones that cannot be switched off and the ones they choose. Channels a class never uses are reported as \"unavailable\", which is not the same as \"off\"." + }, { "operationId": "getPublicBrand", "method": "GET", @@ -16059,6 +16073,48 @@ "summary": "Save email template override", "description": "Saves a tenant override for an editable email template. Required templates cannot be disabled. Only known block keys are accepted." }, + { + "operationId": "saveNotificationPreference", + "method": "PUT", + "pathTemplate": "/api/notification-preferences", + "scopes": [ + "write" + ], + "tag": "notifications", + "tier": "extended", + "inputSchema": { + "parameters": [], + "body": { + "type": "object", + "properties": { + "classId": { + "type": "string", + "description": "The notification class being changed, e.g. review-request." + }, + "channel": { + "type": "string", + "enum": [ + "email", + "sms", + "in_app" + ], + "description": "Which channel this choice applies to: email, sms or in_app." + }, + "enabled": { + "type": "boolean", + "description": "True to receive it again (clears the row); false to switch it off." + } + }, + "required": [ + "classId", + "channel", + "enabled" + ] + } + }, + "summary": "Switch one notification on or off for one channel", + "description": "Records one explicit choice. Turning something back ON deletes the row rather than storing a row that restates the default. A class that is always sent is refused." + }, { "operationId": "saveUserDefaultSignature", "method": "POST", diff --git a/tests/unit/notifications/preferences-api.spec.ts b/tests/unit/notifications/preferences-api.spec.ts new file mode 100644 index 000000000..5fce0de63 --- /dev/null +++ b/tests/unit/notifications/preferences-api.spec.ts @@ -0,0 +1,169 @@ +/** + * The reader's own preferences, over HTTP. + * + * Two things are worth asserting here that no lower layer can: that the SUBJECT + * comes from the session rather than the request, and that the route refuses + * what the send boundary would refuse. The second is not redundancy — the + * boundary makes the guarantee TRUE, and this makes the screen HONEST. A page + * that accepts a change and then ignores it is worse than one that says no. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { OpenAPIHono } from '@hono/zod-openapi'; +import { createTestDb, setupSchema } from '../db'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import * as schema from '../../../server/lib/db/schema'; +import type { HonoConfig } from '../../../server/types/hono'; +import { AppError } from '../../../server/lib/errors'; + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; + +// eslint-disable-next-line import/order +import notificationPreferenceRoutes from '../../../server/api/notification-preferences'; + +const TENANT = 't-prefs-api'; +const ME = 'u-me'; +const SOMEONE_ELSE = 'u-other'; + +let db: BetterSQLite3Database; +let sqlite: { close: () => void }; + +function buildApp(role = 'owner') { + const app = new OpenAPIHono(); + app.onError((err, c) => { + if (err instanceof AppError) { + return c.json({ success: false, error: { code: err.code, message: err.message } }, err.status); + } + return c.json({ success: false, error: { code: 'internal', message: String(err) } }, 500); + }); + app.use('*', async (c, next) => { + c.set('tenantId', TENANT); + c.set('userRole', role); + c.set('user', { sub: ME, role, tenantId: TENANT } as never); + await next(); + }); + app.route('/api', notificationPreferenceRoutes); + return app; +} + +const put = (app: OpenAPIHono, body: unknown) => + app.request('/api/notification-preferences', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }, { DB: {} }); + +beforeEach(async () => { + const fx = createTestDb(); + db = fx.db as BetterSQLite3Database; + sqlite = fx.sqlite; + await setupSchema(fx.sqlite); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockDrizzle as any).mockReturnValue(db); +}); +afterEach(() => sqlite.close()); + +const rows = () => db.select().from(schema.notificationPreferences).all(); + +describe('PUT /api/notification-preferences', () => { + it('writes the mute against the SIGNED-IN reader, whatever the body says', async () => { + // The body carries what changed, never who. Accepting a subject id here + // would let anyone silence anyone. + const res = await put(buildApp('agent'), { + classId: 'agent-new-referral', channel: 'email', enabled: false, + subjectId: SOMEONE_ELSE, userId: SOMEONE_ELSE, + }); + expect(res.status).toBe(200); + + const saved = await rows(); + expect(saved).toHaveLength(1); + expect(saved[0].subjectId).toBe(ME); + expect(saved[0].subjectKind).toBe('user'); + }); + + it('refuses a notification that is always sent', async () => { + const res = await put(buildApp(), { classId: 'password-reset', channel: 'email', enabled: false }); + expect(res.status).toBe(400); + expect(await rows()).toHaveLength(0); + }); + + it('refuses a class it has never heard of', async () => { + const res = await put(buildApp(), { classId: 'not.a.real.class', channel: 'email', enabled: false }); + expect(res.status).toBe(400); + expect(await rows()).toHaveLength(0); + }); + + it('refuses a class this reader is never addressed by', async () => { + // A staff member cannot mute an agent's referral notification. The row + // would be invisible to them and unclearable — nothing renders it. + const res = await put(buildApp('owner'), { classId: 'agent-new-referral', channel: 'email', enabled: false }); + expect(res.status).toBe(400); + expect(await rows()).toHaveLength(0); + }); + + it('refuses a channel the notification never uses', async () => { + // review-request has no in-app form. Storing this would put a row behind + // a control the screen renders as an em dash. + const res = await put(buildApp(), { classId: 'review-request', channel: 'in_app', enabled: false }); + expect(res.status).toBe(400); + expect(await rows()).toHaveLength(0); + }); + + it('DELETES the row when switched back on, rather than storing the default', async () => { + // §3.2 — never store a row that merely restates the default; it makes + // the table grow with the user base instead of with the decisions. + const app = buildApp('agent'); + await put(app, { classId: 'agent-new-referral', channel: 'email', enabled: false }); + expect(await rows()).toHaveLength(1); + + await put(app, { classId: 'agent-new-referral', channel: 'email', enabled: true }); + expect(await rows()).toHaveLength(0); + }); + + it('is idempotent — muting twice leaves one row, not two', async () => { + const app = buildApp('agent'); + await put(app, { classId: 'agent-new-referral', channel: 'email', enabled: false }); + await put(app, { classId: 'agent-new-referral', channel: 'email', enabled: false }); + expect(await rows()).toHaveLength(1); + }); +}); + +describe('GET /api/notification-preferences', () => { + const get = (app: OpenAPIHono) => + app.request('/api/notification-preferences', {}, { DB: {} }); + + it('reports a mute this reader holds as off, and leaves the rest on', async () => { + const app = buildApp('agent'); + await put(app, { classId: 'agent-new-referral', channel: 'email', enabled: false }); + + const body = await (await get(app)).json() as { + data: { youChoose: Array<{ id: string; channels: Record }> }; + }; + const row = body.data.youChoose.find((r) => r.id === 'agent-new-referral')!; + expect(row.channels.email).toBe('off'); + const other = body.data.youChoose.find((r) => r.id === 'agent-report-ready')!; + expect(other.channels.email).toBe('on'); + }); + + it('does not show one reader another reader’s choices', async () => { + await db.insert(schema.notificationPreferences).values({ + id: 'np-theirs', tenantId: TENANT, subjectKind: 'user', subjectId: SOMEONE_ELSE, + classId: 'agent-new-referral', channel: 'email', enabled: false, + createdAt: new Date(), updatedAt: new Date(), + } as never); + + const body = await (await get(buildApp('agent'))).json() as { + data: { youChoose: Array<{ id: string; channels: Record }> }; + }; + expect(body.data.youChoose.find((r) => r.id === 'agent-new-referral')!.channels.email).toBe('on'); + }); + + it('shows an agent the agent list and staff the staff list', async () => { + const staff = await (await get(buildApp('owner'))).json() as { data: { alwaysSent: Array<{ id: string }> } }; + const agent = await (await get(buildApp('agent'))).json() as { data: { youChoose: Array<{ id: string }> } }; + + expect(staff.data.alwaysSent.map((r) => r.id)).toContain('workspace-invitation'); + expect(agent.data.youChoose.map((r) => r.id)).toContain('agent-new-referral'); + expect(agent.data.youChoose.map((r) => r.id)).not.toContain('review-request'); + }); +}); From 951674df3f5048a594f857bdef48ed85cded17bd Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 31 Jul 2026 17:05:44 +0800 Subject: [PATCH 16/48] feat(notifications): per-recipient preference screens for staff, agent and client Retires the three per-event booleans on `users` and gives every audience a screen backed by `notification_preferences`. The old shape was one column per event read by one send method each, so the ~45 notifications nobody added a column for simply had no off switch. Storage rule: store only what DIFFERS from the class default; matching it deletes the row. Stated that way rather than "delete on enable" because `agent-invoice-paid` defaults to OFF -- its column defaulted to false, and `defaultEnabled` moved that default across with the data, so the migration's third backfill statement is inverted. Without it a naive migration had only bad answers: a mute row per user, or agents silently starting to receive invoice mail. Three routes, not one, because the subject differs: - staff -- `users` row, tenant from the JWT (Settings > Profile) - agent -- PER COMPANY, keyed on each company's `contacts` row. An agent account is global (`users.tenant_id IS NULL`) and its JWT carries no tenant, so there is no session tenant to scope a row to. `scope: 'all'` applies one change to every linked company. - client -- portal session cookie; one email can be several contacts in a tenant, so a choice is written to all of them and a mute on any one of them counts. Refusals live in one place (`preference-write.ts`): unknown class, always-sent class, a channel the class never uses, a class this reader is not addressed by. The send boundary is what makes a preference TRUE; these keep the screen HONEST. Also fixes, found in Chrome and invisible to every unit test: - a failed read rendered as "0 notifications you cannot switch off" -- a confident false answer, and the count is the loudest thing on the card. A failure is now distinct from emptiness on all three surfaces. - auto-save had no reply, so a reader could not tell a persisted change from a box that merely looked ticked. Added a saving/saved indicator that never claims "Saved" when the write failed. - `notification-preferences` was never registered in the per-module hono client, so `api["notification-preferences"]` type-checked against nothing. The staff helper typed its client as `any`, which is what hid it. Chrome verification is PARTIAL. The staff surface was driven end to end in both themes: 17 always-sent / 1 choosable, a click writes the row, a second click deletes it, and the bell's settings link works. The agent and client surfaces are covered by unit tests only -- establishing a non-staff session in the browser failed, and `GET /api/agent/profile` (untouched code) fails the same way, so the blocker is session plumbing rather than this change. File-size gate: `settings-profile.tsx` (+1) and `portal-inspection.tsx` are bumped in the baseline. Two real extractions came first -- `settings-notifications.server.ts` and `portal-notification-preferences.ts` -- the residue is route wiring that has to live in the route. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RgBRZQhGELdkaWGorWwWKV --- app/components/agent/AgentNoticeBell.tsx | 3 + app/components/notices/NoticeBell.tsx | 26 + app/components/notices/StaffNoticeBell.tsx | 4 + .../notifications/NotificationPreferences.tsx | 20 +- app/components/portal/ClientPortalHub.tsx | 5 +- app/components/portal/hub/HubSectionSlot.tsx | 14 +- .../portal/hub/PortalNoticeBell.tsx | 5 + .../portal/hub/PortalNotificationSection.tsx | 63 + .../settings/NotificationPreferencesCard.tsx | 75 + app/lib/api-client.server.ts | 15 + app/lib/portal-notification-preferences.ts | 74 + app/lib/settings-notifications.server.ts | 80 + app/routes/agent/settings-profile.test.tsx | 200 +- app/routes/agent/settings-profile.tsx | 227 +- app/routes/public/portal-inspection.tsx | 32 +- app/routes/settings-profile.tsx | 22 +- messages/en/communication.json | 4 + messages/en/components.json | 2 + messages/en/public.json | 14 +- messages/en/settings.json | 5 + migrations/0019_motionless_dark_phoenix.sql | 32 + migrations/meta/0019_snapshot.json | 10007 ++++++++++++++++ migrations/meta/_journal.json | 7 + packages/api-types/index.ts | 6 + scripts/file-size-baseline.json | 5 +- server/api/agent.ts | 7 +- server/api/agent/notification-preferences.ts | 166 + server/api/notification-preferences.ts | 75 +- server/api/portal/notification-preferences.ts | 148 + server/index.ts | 3 + server/lib/db/schema/tenant/user.ts | 3 - server/lib/mcp/openapi-snapshot.json | 157 + server/lib/notifications/classes.ts | 28 +- server/lib/notifications/preference-port.ts | 12 +- server/lib/notifications/preference-write.ts | 104 + server/lib/notifications/screen-model.ts | 15 +- server/lib/validations/agent.schema.ts | 6 - server/services/agent/companies.ts | 47 + server/services/agent/profile.ts | 8 - server/services/email/agent.ts | 25 +- tests/unit/agent/profile-get.spec.ts | 11 +- .../agent-notification-prefs-schema.spec.ts | 37 +- .../agents/agent-notification-prefs.spec.ts | 231 +- .../agents/agent-service-listings.spec.ts | 24 +- .../agent-preferences-api.spec.ts | 250 + .../notifications/preferences-api.spec.ts | 59 +- tests/unit/notifications/screen-model.spec.ts | 26 +- 47 files changed, 11976 insertions(+), 413 deletions(-) create mode 100644 app/components/portal/hub/PortalNotificationSection.tsx create mode 100644 app/components/settings/NotificationPreferencesCard.tsx create mode 100644 app/lib/portal-notification-preferences.ts create mode 100644 app/lib/settings-notifications.server.ts create mode 100644 migrations/0019_motionless_dark_phoenix.sql create mode 100644 migrations/meta/0019_snapshot.json create mode 100644 server/api/agent/notification-preferences.ts create mode 100644 server/api/portal/notification-preferences.ts create mode 100644 server/lib/notifications/preference-write.ts create mode 100644 server/services/agent/companies.ts create mode 100644 tests/unit/notifications/agent-preferences-api.spec.ts diff --git a/app/components/agent/AgentNoticeBell.tsx b/app/components/agent/AgentNoticeBell.tsx index 9889a4c6f..ff9022798 100644 --- a/app/components/agent/AgentNoticeBell.tsx +++ b/app/components/agent/AgentNoticeBell.tsx @@ -47,6 +47,9 @@ export function AgentNoticeBell({ notices, unread }: { notices: NoticeRowData[]; return ( void; @@ -113,6 +125,20 @@ export function NoticeBell({ onRemedy(remedy); }} /> + {settingsHref && ( + // Sticky to the bottom for the same reason the header is sticky to + // the top: a reader scrolling a long list must not have to reach + // the end of it to find the way out. +
+ setOpen(false)} + className="text-[12px] font-semibold text-ih-fg-2 hover:text-ih-fg-1 transition-colors" + > + {m.notice_panel_settings()} + +
+ )} diff --git a/app/components/notices/StaffNoticeBell.tsx b/app/components/notices/StaffNoticeBell.tsx index b91b8dcef..b185341a4 100644 --- a/app/components/notices/StaffNoticeBell.tsx +++ b/app/components/notices/StaffNoticeBell.tsx @@ -44,6 +44,10 @@ export function StaffNoticeBell() { return ( Profile, not a second + // place the setting lives. The bell is where a reader is already asking + // "what are you all sending me". + settingsHref="/settings/profile#notifications" notices={data.notices} unread={data.unread} /* The bell lives in the sidebar, so the panel must open INTO the diff --git a/app/components/notifications/NotificationPreferences.tsx b/app/components/notifications/NotificationPreferences.tsx index dd59b7bf2..aeb53707d 100644 --- a/app/components/notifications/NotificationPreferences.tsx +++ b/app/components/notifications/NotificationPreferences.tsx @@ -49,6 +49,16 @@ export interface NotificationPreferencesProps { onChange: (classId: string, channel: ChannelId, enabled: boolean) => void; /** Disables every control while a save is in flight. */ busy?: boolean; + /** + * Whether the last change is in flight, landed, or nothing has happened. + * + * There is no Save button, and that is deliberate: a single switch does not + * need one, and adding it would invent the question "did that save?" for an + * action that is already one click. But auto-save without a reply invents + * the SAME question silently — a reader cannot tell a persisted change from + * a box that merely looks ticked. This is the reply. + */ + status?: "idle" | "saving" | "saved"; } const CHANNELS: ReadonlyArray<{ id: ChannelId; label: () => string }> = [ @@ -94,7 +104,7 @@ function ChannelCell({ } export function NotificationPreferences({ - alwaysSent, youChoose, onChange, busy = false, + alwaysSent, youChoose, onChange, busy = false, status = "idle", }: NotificationPreferencesProps) { return (
@@ -142,6 +152,14 @@ export function NotificationPreferences({

{m.notif_prefs_choose_heading()}

+ {/* aria-live so the confirmation reaches a reader who cannot + see the row they just changed. Same words either way — + the switch already said WHAT changed. */} + + {status === "saving" ? m.notif_prefs_saving() + : status === "saved" ? m.notif_prefs_saved() + : ""} +
{youChoose.length === 0 ? ( diff --git a/app/components/portal/ClientPortalHub.tsx b/app/components/portal/ClientPortalHub.tsx index 1408ad3e6..e8e257c16 100644 --- a/app/components/portal/ClientPortalHub.tsx +++ b/app/components/portal/ClientPortalHub.tsx @@ -17,7 +17,10 @@ export type HubSection = | "progress" | "messages" | "repair" - | "documents"; + | "documents" + // Reached from the BELL, never from navItems() below: the eight tabs are + // facts about this inspection and this is a fact about the reader. + | "notifications"; export interface HubLinkCtx { tenant: string; diff --git a/app/components/portal/hub/HubSectionSlot.tsx b/app/components/portal/hub/HubSectionSlot.tsx index 93518d817..27111a291 100644 --- a/app/components/portal/hub/HubSectionSlot.tsx +++ b/app/components/portal/hub/HubSectionSlot.tsx @@ -26,6 +26,8 @@ import type { InvoiceLoaderResult, AgreementLoaderResult, } from "~/lib/section-loaders"; +import type { NotificationsLoaderResult } from "~/lib/portal-notification-preferences"; +import { PortalNotificationSection } from "~/components/portal/hub/PortalNotificationSection"; import type { AgentReportContext } from "~/lib/agent-report-context"; import type { ReportLoaderResult } from "~/components/portal/sections/ReportView"; import type { LoaderResult as RepairLoaderResult } from "~/components/portal/sections/RepairBuilderSection"; @@ -53,6 +55,7 @@ interface HubSectionSlotProps { opts: { category: DocumentCategory; visibility: DocumentVisibility; label?: string }, ) => void; onDelete: (docId: string) => void; + notifications: NotificationsLoaderResult | null; } export function HubSectionSlot({ @@ -74,11 +77,20 @@ export function HubSectionSlot({ docError, onUpload, onDelete, + notifications, }: HubSectionSlotProps): React.ReactNode { // Build the active section's body (decision B/C). Overview renders the status // cards inside the Hub itself; this slot is only used on non-overview tabs. let sectionSlot: React.ReactNode = null; - if (section === "documents") { + if (section === "notifications" && notifications) { + sectionSlot = ( + + ); + } else if (section === "documents") { sectionSlot = ( (); const revalidator = useRevalidator(); @@ -75,6 +79,7 @@ export function PortalNoticeBell({ return ( (); + const result = fetcher.data?.intent === "notification-preference" ? fetcher.data : null; + const saveError = result && result.ok === false ? result.error : null; + + // "saved" persists after the fetcher goes idle, so the confirmation is still + // on screen when the reader looks up from the switch they just moved. + const status = fetcher.state !== "idle" ? "saving" as const + : saveError ? "idle" as const + : fetcher.data ? "saved" as const : "idle" as const; + + function save(classId: string, channel: ChannelId, enabled: boolean) { + fetcher.submit( + { intent: "notification-preference", classId, channel, enabled: String(enabled) }, + { method: "post" }, + ); + } + + return ( +
+
+

{m.portal_notif_heading()}

+

{m.portal_notif_desc()}

+
+ {(error || saveError) && ( +

{error ?? saveError}

+ )} + +
+ ); +} diff --git a/app/components/settings/NotificationPreferencesCard.tsx b/app/components/settings/NotificationPreferencesCard.tsx new file mode 100644 index 000000000..361a9daf9 --- /dev/null +++ b/app/components/settings/NotificationPreferencesCard.tsx @@ -0,0 +1,75 @@ +import { useFetcher } from "react-router"; +import { + NotificationPreferences, + type AlwaysSentItem, + type ChannelId, + type ChoiceRow, +} from "~/components/notifications/NotificationPreferences"; +import { m } from "~/paraglide/messages"; + +/** + * The staff member's own notification settings, on Settings → Profile (§4.1). + * + * A card rather than a section inlined into the route, for two reasons that are + * the same reason: the route is already long, and this is the third surface to + * render the same model. The shared component below decides what a reader sees; + * this only supplies the persistence, which is the one part each surface has to + * own — staff post to their own route's action, an agent's write also names a + * company, and the client portal authenticates by token. + * + * Staff have very little here on purpose. Almost everything they receive is + * account access, a money or legal record, or office dispatch an individual is + * not allowed to silence for the whole company (§2.5) — so the "always sent" + * section carries the page and the empty-ish choose list is the honest answer, + * not a bug. + */ +export function NotificationPreferencesCard({ + alwaysSent, youChoose, loadError, +}: { + alwaysSent: AlwaysSentItem[]; + youChoose: ChoiceRow[]; + /** The read failed. Distinct from "nothing to show" — see below. */ + loadError: string | null; +}) { + const fetcher = useFetcher<{ success?: boolean; error?: string; intent?: string }>(); + const result = fetcher.data?.intent === "save-notification" ? fetcher.data : null; + const error = result && result.success === false ? result.error : null; + + // "saved" persists after the fetcher goes idle, so the confirmation is still + // on screen when the reader looks up from the switch they just moved. + const status = fetcher.state !== "idle" ? "saving" as const + : error ? "idle" as const + : fetcher.data ? "saved" as const : "idle" as const; + + function save(classId: string, channel: ChannelId, enabled: boolean) { + fetcher.submit( + { intent: "save-notification", classId, channel, enabled: String(enabled) }, + { method: "post" }, + ); + } + + return ( +
+

+ {m.settings_notifications_eyebrow()} +

+

{m.settings_notifications_heading()}

+

{m.settings_notifications_desc()}

+ {error &&

{error}

} + {loadError ? ( + // Never render the two counts when the read failed. "0 notifications + // you cannot switch off" is a confident false answer, and the count is + // the loudest thing on the card. +

{loadError}

+ ) : ( + + )} +
+ ); +} diff --git a/app/lib/api-client.server.ts b/app/lib/api-client.server.ts index 9489b510d..7750ce8e3 100644 --- a/app/lib/api-client.server.ts +++ b/app/lib/api-client.server.ts @@ -46,6 +46,9 @@ import type { PlacesApi, PortalApi, PortalNoticesApi, + NotificationPreferencesApi, + AgentNotificationPreferencesApi, + PortalNotificationPreferencesApi, AgentNoticesApi, ProfileApi, PublicShareApi, @@ -173,6 +176,12 @@ export interface Api { // same prefixes, typed independently for the same structural-depth reason // as agentMagicLogin above. portalNotices: ReturnType>; + // Notification preferences (§4) — one client per audience. Each mounts at + // a prefix another module already owns, so they need their own keys for + // the same reason portalNotices does (hono/client type-collapse, C-10). + notificationPrefs: ReturnType>; + agentNotificationPrefs: ReturnType>; + portalNotificationPrefs: ReturnType>; agentNotices: ReturnType>; profile: ReturnType>; publicShare: ReturnType>; @@ -250,6 +259,9 @@ const MOUNT: Record = { places: "/api/places", portal: "/api/portal", portalNotices: "/api/portal", + notificationPrefs: "/api", + agentNotificationPrefs: "/api/agent", + portalNotificationPrefs: "/api/portal", agentNotices: "/api/agent", profile: "/api/profile", publicShare: "/api/public", @@ -345,6 +357,9 @@ export function createApi(context: LoadContext, opts: CreateApiOptions = {}): Ap places: mk(MOUNT.places), portal: mk(MOUNT.portal), portalNotices: mk(MOUNT.portalNotices), + notificationPrefs: mk(MOUNT.notificationPrefs), + agentNotificationPrefs: mk(MOUNT.agentNotificationPrefs), + portalNotificationPrefs: mk(MOUNT.portalNotificationPrefs), agentNotices: mk(MOUNT.agentNotices), profile: mk(MOUNT.profile), publicShare: mk(MOUNT.publicShare), diff --git a/app/lib/portal-notification-preferences.ts b/app/lib/portal-notification-preferences.ts new file mode 100644 index 000000000..efae9b228 --- /dev/null +++ b/app/lib/portal-notification-preferences.ts @@ -0,0 +1,74 @@ +import { createApi } from "~/lib/api-client.server"; +import { m } from "~/paraglide/messages"; +import type { LoadContext } from "~/lib/load-context"; +import type { AlwaysSentItem, ChoiceRow } from "~/components/notifications/NotificationPreferences"; + +/** + * The client Hub's notification-settings seam (spec §4.1) — its own module + * rather than another entry in `section-loaders.ts`, because it is the one + * "section" that is not about the inspection. It is reached from the bell, it + * covers everything the company sends this person, and the inspection in the + * URL is only where they happened to be standing. + */ +export interface NotificationsLoaderResult { + alwaysSent: AlwaysSentItem[]; + youChoose: ChoiceRow[]; + error: string | null; +} + +/** + * The client's own notification settings for THIS company. + * + * Same portal-session cookie as the Notices reads, and for the same reason: + * this is the only thing that identifies the reader. Unlike the other sections + * it is not about the inspection at all — it is reached from the bell, and the + * inspection in the URL is only where the reader happened to be standing. + */ +export async function loadNotificationsSection( + context: LoadContext, + tenant: string, + cookieForApi: string, +): Promise { + try { + const api = createApi(context); + const res = await api.portalNotificationPrefs[":tenant"]["notification-preferences"].$get( + { param: { tenant } }, + { headers: { Cookie: cookieForApi } }, + ); + if (!res.ok) { + return { alwaysSent: [], youChoose: [], error: m.helper_section_service_unavailable() }; + } + const body = (await res.json()) as { data?: { alwaysSent: AlwaysSentItem[]; youChoose: ChoiceRow[] } }; + const d = body.data ?? { alwaysSent: [], youChoose: [] }; + return { alwaysSent: d.alwaysSent, youChoose: d.youChoose, error: null }; + } catch { + return { alwaysSent: [], youChoose: [], error: m.helper_section_service_unavailable() }; + } +} + +/** + * One explicit choice, from the Hub's own action. + * + * The portal-session cookie travels explicitly because the typed client does + * not forward the browser's — the same reason the Notices writes pass it. + */ +export async function savePortalNotificationChoice( + context: LoadContext, + tenant: string, + cookie: string, + formData: FormData, +): Promise<{ ok: boolean; error?: string }> { + const api = createApi(context); + const res = await api.portalNotificationPrefs[":tenant"]["notification-preferences"].$put( + { + param: { tenant }, + json: { + classId: String(formData.get("classId") ?? ""), + channel: String(formData.get("channel") ?? "email") as "email" | "sms" | "in_app", + enabled: formData.get("enabled") === "true", + }, + }, + { headers: { Cookie: cookie } }, + ); + return res.ok ? { ok: true } : { ok: false, error: m.portal_notif_save_error() }; +} diff --git a/app/lib/settings-notifications.server.ts b/app/lib/settings-notifications.server.ts new file mode 100644 index 000000000..a235fc0bb --- /dev/null +++ b/app/lib/settings-notifications.server.ts @@ -0,0 +1,80 @@ +import type { AlwaysSentItem, ChoiceRow } from "~/components/notifications/NotificationPreferences"; +import type { Api as CoreApi } from "~/lib/api-client.server"; +import { m } from "~/paraglide/messages"; + +/** + * The Settings → Profile page's seam onto the notification-preferences API. + * + * Extracted from the route because it is a self-contained unit — one read, one + * write, and the failure policy that ties them together — and because the route + * is already long enough that the file-size gate says so out loud. + */ + +export interface NotificationScreen { + alwaysSent: AlwaysSentItem[]; + youChoose: ChoiceRow[]; + /** Set when the read failed. NOT the same as "you have nothing". */ + error: string | null; +} + +/** + * A failed read is NOT an empty screen. + * + * Rendering `[]` on failure printed "0 notifications you cannot switch off" + * above a paragraph explaining why we always send them — a confident, + * false answer, and the count is the loudest thing on the card. Caught in + * Chrome; every unit test called it green because they all stubbed a 200. + */ +const FAILED = (): NotificationScreen => + ({ alwaysSent: [], youChoose: [], error: m.settings_notifications_unavailable() }); + +/** + * Only the one client this module touches. Typing it as `any` would be the + * cheaper line and it is exactly what hid a missing client registration once + * already: `api["notification-preferences"]` type-checked against nothing. + */ +type Api = { notificationPrefs: CoreApi["notificationPrefs"] }; + +/** + * A failed read yields an EMPTY screen rather than throwing. + * + * The profile form and the notification card share a page but not a subject: a + * notifications endpoint that 500s must not take the name, photo and signature + * fields down with it. An empty card says "nothing to show", which is wrong but + * recoverable on reload; a dead page is neither. + */ +export async function loadNotificationScreen(api: Api): Promise { + try { + const res = await api.notificationPrefs["notification-preferences"].$get(); + if (!res.ok) return FAILED(); + const body = (await res.json()) as { data?: Omit }; + return body.data ? { ...body.data, error: null } : FAILED(); + } catch { + return FAILED(); + } +} + +/** + * One explicit choice, from the card's form data. + * + * The API refuses anything the send boundary would ignore — a class that is + * always sent, a channel it never uses, a class this reader is not addressed + * by — so a non-ok response here is a real answer and is surfaced, not + * swallowed. A screen that accepts a change and then ignores it is worse than + * one that says no. + */ +export async function saveNotificationChoice( + api: Api, + fd: FormData, +): Promise<{ success: boolean; error: string | null }> { + const res = await api.notificationPrefs["notification-preferences"].$put({ + json: { + classId: String(fd.get("classId") ?? ""), + channel: String(fd.get("channel") ?? "email") as "email" | "sms" | "in_app", + enabled: fd.get("enabled") === "true", + }, + }); + return res.ok + ? { success: true, error: null } + : { success: false, error: m.settings_notifications_error() }; +} diff --git a/app/routes/agent/settings-profile.test.tsx b/app/routes/agent/settings-profile.test.tsx index 54e48c848..991639cd7 100644 --- a/app/routes/agent/settings-profile.test.tsx +++ b/app/routes/agent/settings-profile.test.tsx @@ -14,6 +14,8 @@ import { createRoutesStub } from "react-router"; const profileGet = vi.fn(); const profilePost = vi.fn(); +const prefsGet = vi.fn(); +const prefsPut = vi.fn(); vi.mock("~/lib/session.server", () => ({ requireToken: vi.fn(async () => "tok-test"), @@ -24,6 +26,11 @@ vi.mock("~/lib/api-client.server", () => ({ agent: { profile: { $get: profileGet, $post: profilePost }, }, + // Its own per-module client: the preferences router mounts at /api/agent, + // a prefix `agent` already owns, so it cannot share that client. + agentNotificationPrefs: { + "notification-preferences": { $get: prefsGet, $put: prefsPut }, + }, })), })); @@ -37,9 +44,9 @@ function jsonRes(body: unknown, ok = true) { return { ok, json: async () => body } as unknown as Response; } -function loaderArgs(): LoaderArgs { +function loaderArgs(search = ""): LoaderArgs { return { - request: new Request("http://app.example.com/agent-settings/profile"), + request: new Request(`http://app.example.com/agent-settings/profile${search}`), context: {} as never, params: {}, } as unknown as LoaderArgs; @@ -62,15 +69,40 @@ const SAMPLE_AGENT = { name: "Jane", email: "jane@x.com", slug: "jane", - notifyOnReferral: true, - notifyOnReport: false, - notifyOnPaid: true, timezone: "America/New_York", }; +/** + * Two companies, because one is the case that hides the bug: with a single + * company the selector, the "apply to all" checkbox and the per-company write + * all look the same as a global setting. + */ +const SAMPLE_SCREEN = { + companies: [ + { id: "t-acme", name: "Acme Inspections" }, + { id: "t-bolt", name: "Bolt Home Services" }, + ], + selected: "t-acme", + alwaysSent: [{ id: "agent-login-link", label: "Sign-in link", channels: ["email"] }], + youChoose: [ + { + id: "agent-new-referral", + label: "A new referral is booked", + channels: { email: "on", sms: "unavailable", in_app: "unavailable" }, + }, + { + id: "agent-report-ready", + label: "A report is ready to read", + channels: { email: "off", sms: "unavailable", in_app: "unavailable" }, + }, + ], +}; + beforeEach(() => { profileGet.mockReset().mockResolvedValue(jsonRes({ data: SAMPLE_AGENT })); profilePost.mockReset().mockResolvedValue(jsonRes({ data: { ok: true } })); + prefsGet.mockReset().mockResolvedValue(jsonRes({ data: SAMPLE_SCREEN })); + prefsPut.mockReset().mockResolvedValue(jsonRes({ success: true, applied: 1 })); }); describe("agent settings-profile loader", () => { @@ -83,11 +115,26 @@ describe("agent settings-profile loader", () => { it("degrades to safe defaults when the GET fails", async () => { profileGet.mockResolvedValue(jsonRes(null, false)); const data = await loader(loaderArgs()); - expect(data.agent).toEqual({ - name: null, email: "", slug: null, - notifyOnReferral: true, notifyOnReport: true, notifyOnPaid: false, - timezone: null, - }); + expect(data.agent).toEqual({ name: null, email: "", slug: null, timezone: null }); + }); + + it("reads the notification screen for the company named in the URL", async () => { + await loader(loaderArgs("?company=t-bolt")); + expect(prefsGet).toHaveBeenCalledWith({ query: { companyId: "t-bolt" } }); + }); + + it("lets the server pick the company when the URL names none", async () => { + await loader(loaderArgs()); + expect(prefsGet).toHaveBeenCalledWith({ query: {} }); + }); + + it("degrades to an empty screen rather than failing the page", async () => { + // A notification read that 500s must not take the slug and timezone cards + // down with it — they are a different subject entirely. + prefsGet.mockResolvedValue(jsonRes(null, false)); + const data = await loader(loaderArgs()); + expect(data.notifications.companies).toEqual([]); + expect(data.agent.slug).toBe("jane"); }); }); @@ -106,19 +153,39 @@ describe("agent settings-profile action", () => { expect(res).toMatchObject({ ok: false, intent: "save-slug", error: "Slug already taken" }); }); - it("intent=save-notifications posts all three toggles", async () => { + it("intent=save-notifications names the class, the channel and the company", async () => { const res = await action(actionArgs({ intent: "save-notifications", - notifyOnReferral: "false", - notifyOnReport: "true", - notifyOnPaid: "true", + classId: "agent-new-referral", + channel: "email", + enabled: "false", + scope: "company", + companyId: "t-acme", })); - expect(profilePost).toHaveBeenCalledWith({ - json: { notifyOnReferral: false, notifyOnReport: true, notifyOnPaid: true }, + expect(prefsPut).toHaveBeenCalledWith({ + json: { + classId: "agent-new-referral", channel: "email", enabled: false, + scope: "company", companyId: "t-acme", + }, }); expect(res).toMatchObject({ ok: true, intent: "save-notifications" }); }); + it("intent=save-notifications with scope=all sends no company at all", async () => { + // Sending a companyId alongside scope=all would be two answers to one + // question, and the server would have to decide which one meant it. + await action(actionArgs({ + intent: "save-notifications", + classId: "agent-new-referral", channel: "email", enabled: "false", + scope: "all", companyId: "t-acme", + })); + expect(prefsPut).toHaveBeenCalledWith({ + json: { + classId: "agent-new-referral", channel: "email", enabled: false, scope: "all", + }, + }); + }); + it("intent=save-timezone posts the chosen IANA zone", async () => { const res = await action(actionArgs({ intent: "save-timezone", timezone: "America/Chicago" })); expect(profilePost).toHaveBeenCalledWith({ json: { timezone: "America/Chicago" } }); @@ -139,36 +206,80 @@ describe("agent settings-profile action", () => { */ describe("AgentSettingsProfilePage rendering", () => { function renderPage(opts: { - agent?: typeof SAMPLE_AGENT; + notifications?: typeof SAMPLE_SCREEN | { companies: []; selected: null; alwaysSent: []; youChoose: [] }; action?: (args: { request: Request }) => unknown; } = {}) { const Stub = createRoutesStub([ { path: "/agent-settings/profile", Component: AgentSettingsProfilePage, - loader: () => ({ agent: opts.agent ?? SAMPLE_AGENT }), + loader: () => ({ + agent: SAMPLE_AGENT, + notifications: opts.notifications ?? SAMPLE_SCREEN, + }), action: opts.action ?? (async () => ({ ok: true, intent: "save-slug", error: undefined })), }, ]); return render(); } - it("seeds the slug input and toggle states from loader data", async () => { - const { findByDisplayValue, getByText } = renderPage(); + it("seeds the slug input from loader data", async () => { + const { findByDisplayValue } = renderPage(); await findByDisplayValue("jane"); - // notifyOnReferral: true, notifyOnReport: false, notifyOnPaid: true — just - // assert the section renders with the loader-seeded titles (state itself - // is covered by the switch aria-checked assertions below). + }); + + it("names the company whose settings are on screen", async () => { + // The whole card is one company's answer. A reader who cannot see which + // company they are editing is one click from silencing the wrong firm. + // A selectCompany(e.target.value)} + disabled={notifyFetcher.state !== "idle"} + options={notifications.companies.map((co) => ({ value: co.id, label: co.name }))} + /> + {notifications.companies.length > 1 && ( + // Only offered when there is more than one company — otherwise + // "all" and "this one" are the same act, and the checkbox would + // be asking a question with one answer. + + )} +
+ +
+ )} -
- saveNotifications({ ...notify, notifyOnReferral: v })} - /> - saveNotifications({ ...notify, notifyOnReport: v })} - /> - saveNotifications({ ...notify, notifyOnPaid: v })} - /> -
{/* Timezone */} @@ -234,31 +299,3 @@ export default function AgentSettingsProfilePage() { ); } - -function ToggleRow({ title, subtitle, checked, onChange }: { - title: string; - subtitle: string; - checked: boolean; - onChange: (next: boolean) => void; -}) { - return ( -
-
-

{title}

-

{subtitle}

-
- -
- ); -} diff --git a/app/routes/public/portal-inspection.tsx b/app/routes/public/portal-inspection.tsx index 2be9d7f3f..2a1740d54 100644 --- a/app/routes/public/portal-inspection.tsx +++ b/app/routes/public/portal-inspection.tsx @@ -52,6 +52,11 @@ import { type InvoiceLoaderResult, type AgreementLoaderResult, } from "~/lib/section-loaders"; +import { + loadNotificationsSection, + savePortalNotificationChoice, + type NotificationsLoaderResult, +} from "~/lib/portal-notification-preferences"; import { loadAgentReportContext, type AgentReportContext } from "~/lib/agent-report-context"; import { resolvePortalSession } from "~/lib/portal-exchange"; import { @@ -132,8 +137,13 @@ export async function loader({ params, request, context }: Route.LoaderArgs) { let repair: RepairLoaderResult | null = null; let invoice: InvoiceLoaderResult | null = null; let agreement: AgreementLoaderResult | null = null; + let notifications: NotificationsLoaderResult | null = null; - if (section === "documents") { + if (section === "notifications") { + // Not about the inspection at all — the reader arrived from the bell, and + // the setting covers everything this company sends them. + notifications = await loadNotificationsSection(context, tenant, cookieForApi); + } else if (section === "documents") { // Client documents (unified portal section ⑦) — fetch using the SAME cookie // value used for the overview call. Best-effort: a non-OK response → empty. documents = []; @@ -213,7 +223,7 @@ export async function loader({ params, request, context }: Route.LoaderArgs) { // Step 5 — render the hub. return new Response( - JSON.stringify({ overview, ctx, section, brand, documents, report, progress, repair, invoice, agreement, agentReport, notices: noticesPayload }), + JSON.stringify({ overview, ctx, section, brand, documents, report, progress, repair, invoice, agreement, agentReport, notices: noticesPayload, notifications }), { headers: { "Content-Type": "application/json", @@ -241,6 +251,13 @@ export async function action({ request, params, context }: Route.ActionArgs) { // C3 — the Notices bell's writes. The session cookie travels explicitly // because the typed client does not forward the browser's. + if (intent === "notification-preference") { + const r = await savePortalNotificationChoice( + context, tenant, request.headers.get("cookie") ?? "", formData, + ); + return { ...r, intent }; + } + const noticeResult = await handlePortalNoticeIntent( api, tenant, request.headers.get("cookie") ?? "", intent, String(formData.get("noticeId") ?? ""), @@ -259,7 +276,7 @@ export async function action({ request, params, context }: Route.ActionArgs) { /* ------------------------------------------------------------------ */ export default function PortalInspection() { - const { overview, ctx, section, brand, documents, report, progress, repair, invoice, agreement, agentReport, notices } = useLoaderData() as { + const { overview, ctx, section, brand, documents, report, progress, repair, invoice, agreement, agentReport, notices, notifications } = useLoaderData() as { overview: StatusOverview; ctx: { tenant: string; inspectionId: string; token: string; signerToken: string | null }; section: HubSection; @@ -272,6 +289,7 @@ export default function PortalInspection() { agreement: AgreementLoaderResult | null; agentReport: AgentReportContext | null; notices: PortalNoticesPayload; + notifications: NotificationsLoaderResult | null; }; const revalidator = useRevalidator(); const [searchParams] = useSearchParams(); @@ -369,6 +387,7 @@ export default function PortalInspection() { docError={docError} onUpload={onUpload} onDelete={onDelete} + notifications={notifications} /> ); @@ -383,7 +402,12 @@ export default function PortalInspection() { onSignOut={isAgent ? undefined : () => void signOut(tenant)} bellSlot={ isAgent ? undefined : ( - + ) } /> diff --git a/app/routes/settings-profile.tsx b/app/routes/settings-profile.tsx index febaec36a..55e6e13a0 100644 --- a/app/routes/settings-profile.tsx +++ b/app/routes/settings-profile.tsx @@ -17,6 +17,8 @@ import { TIMEZONE_SELECT_OPTIONS } from "~/lib/timezones"; import { LOCALE_OPTIONS } from "~/lib/locales"; import { SectionNav } from "~/components/settings/SectionNav"; import { CredentialsEditor, type EditorCredential } from "~/components/settings/CredentialsEditor"; +import { NotificationPreferencesCard } from "~/components/settings/NotificationPreferencesCard"; +import { loadNotificationScreen, saveNotificationChoice } from "~/lib/settings-notifications.server"; import { m } from "~/paraglide/messages"; /* ------------------------------------------------------------------ */ @@ -43,13 +45,14 @@ interface Profile { export async function loader({ request, context }: Route.LoaderArgs) { const token = await requireToken(context, request); const api = createApi(context, { token }); - const [res, credRes] = await Promise.all([ + const [res, credRes, notifications] = await Promise.all([ api.profile.index.$get(), api.credentials.index.$get(), + loadNotificationScreen(api), ]); const body = res.ok ? ((await res.json()) as Record) : {}; const credBody = credRes.ok ? ((await credRes.json()) as { data?: EditorCredential[] }) : { data: [] }; - return { profile: (body.data ?? {}) as Profile, credentials: credBody.data ?? [] }; + return { profile: (body.data ?? {}) as Profile, credentials: credBody.data ?? [], notifications }; } /* ------------------------------------------------------------------ */ @@ -62,6 +65,10 @@ export async function action({ request, context }: Route.ActionArgs) { const fd = await request.formData(); const intent = fd.get("intent") as string | null; + if (intent === "save-notification") { + return { ...(await saveNotificationChoice(api, fd)), intent }; + } + // Handle save-signature intent from the SignaturePad fetcher if (intent === "save-signature") { const signatureBase64 = fd.get("signatureBase64") as string | null; @@ -159,7 +166,7 @@ export async function action({ request, context }: Route.ActionArgs) { /* ------------------------------------------------------------------ */ export default function SettingsProfilePage() { - const { profile, credentials } = useLoaderData(); + const { profile, credentials, notifications } = useLoaderData(); const actionData = useActionData(); const [avatarSource, setAvatarSource] = useState(null); // DB-12 / IA-26 — useSessionContext / tenantSlug removed; slug section gone. @@ -253,6 +260,7 @@ export default function SettingsProfilePage() { { id: "signature", label: m.settings_profile_signature_heading() }, { id: "saved-signature", label: m.settings_profile_saved_signature_heading() }, { id: "credentials", label: m.settings_profile_credentials_heading() }, + { id: "notifications", label: m.settings_notifications_eyebrow() }, ]; return ( @@ -485,6 +493,14 @@ export default function SettingsProfilePage() { onUpload={onCredUpload} /> +
+ +
+ {avatarSource && ( statement-breakpoint +INSERT INTO notification_preferences (id, tenant_id, subject_kind, subject_id, class_id, channel, enabled, created_at, updated_at) +SELECT lower(hex(randomblob(16))), tenant_id, 'user', id, 'agent-report-ready', 'email', 0, + CAST(strftime('%s','now') AS INTEGER) * 1000, CAST(strftime('%s','now') AS INTEGER) * 1000 +FROM users WHERE is_report_notification_enabled = 0; +--> statement-breakpoint +INSERT INTO notification_preferences (id, tenant_id, subject_kind, subject_id, class_id, channel, enabled, created_at, updated_at) +SELECT lower(hex(randomblob(16))), tenant_id, 'user', id, 'agent-invoice-paid', 'email', 1, + CAST(strftime('%s','now') AS INTEGER) * 1000, CAST(strftime('%s','now') AS INTEGER) * 1000 +FROM users WHERE is_paid_notification_enabled = 1; +--> statement-breakpoint +ALTER TABLE `users` DROP COLUMN `is_referral_notification_enabled`;--> statement-breakpoint +ALTER TABLE `users` DROP COLUMN `is_report_notification_enabled`;--> statement-breakpoint +ALTER TABLE `users` DROP COLUMN `is_paid_notification_enabled`; \ No newline at end of file diff --git a/migrations/meta/0019_snapshot.json b/migrations/meta/0019_snapshot.json new file mode 100644 index 000000000..39bcb8ff3 --- /dev/null +++ b/migrations/meta/0019_snapshot.json @@ -0,0 +1,10007 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0ea907f9-b228-4f46-b358-222449c8d22b", + "prevId": "c34715cc-c710-43f0-9f1a-2cf47fd89451", + "tables": { + "agreement_requests": { + "name": "agreement_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agreement_id": { + "name": "agreement_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "signature_base64": { + "name": "signature_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_at": { + "name": "signed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "viewed_at": { + "name": "viewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_at": { + "name": "sent_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_signature_base64": { + "name": "inspector_signature_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_signed_at": { + "name": "inspector_signed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_user_id": { + "name": "inspector_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_snapshot": { + "name": "content_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completion_policy": { + "name": "completion_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'all'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purged_at": { + "name": "purged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agreement_requests_token_unique": { + "name": "agreement_requests_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "idx_agreement_requests_verify_token": { + "name": "idx_agreement_requests_verify_token", + "columns": [ + "verification_token" + ], + "isUnique": true + }, + "idx_agreement_requests_tenant": { + "name": "idx_agreement_requests_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_agreement_requests_inspection": { + "name": "idx_agreement_requests_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "idx_agreement_requests_token_hash": { + "name": "idx_agreement_requests_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "agreement_requests_tenant_id_tenants_id_fk": { + "name": "agreement_requests_tenant_id_tenants_id_fk", + "tableFrom": "agreement_requests", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agreement_requests_inspection_id_inspections_id_fk": { + "name": "agreement_requests_inspection_id_inspections_id_fk", + "tableFrom": "agreement_requests", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agreement_requests_agreement_id_agreements_id_fk": { + "name": "agreement_requests_agreement_id_agreements_id_fk", + "tableFrom": "agreement_requests", + "tableTo": "agreements", + "columnsFrom": [ + "agreement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agreement_requests_inspector_user_id_users_id_fk": { + "name": "agreement_requests_inspector_user_id_users_id_fk", + "tableFrom": "agreement_requests", + "tableTo": "users", + "columnsFrom": [ + "inspector_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agreement_signers": { + "name": "agreement_signers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'client'" + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_enc": { + "name": "token_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "signature_base64": { + "name": "signature_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_at": { + "name": "signed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "viewed_at": { + "name": "viewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "on_behalf_of": { + "name": "on_behalf_of", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "on_behalf_disclaimer": { + "name": "on_behalf_disclaimer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_reminded_at": { + "name": "last_reminded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_agreement_signers_tenant_request": { + "name": "idx_agreement_signers_tenant_request", + "columns": [ + "tenant_id", + "request_id" + ], + "isUnique": false + }, + "idx_agreement_signers_request_email": { + "name": "idx_agreement_signers_request_email", + "columns": [ + "request_id", + "email" + ], + "isUnique": true + }, + "idx_agreement_signers_token_hash": { + "name": "idx_agreement_signers_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agreements": { + "name": "agreements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_agreements_tenant": { + "name": "idx_agreements_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "agreements_tenant_id_tenants_id_fk": { + "name": "agreements_tenant_id_tenants_id_fk", + "tableFrom": "agreements", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automation_logs": { + "name": "automation_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_role_key": { + "name": "recipient_role_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'email'" + }, + "send_at": { + "name": "send_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_contact_id": { + "name": "recipient_contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notice_id": { + "name": "notice_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_logs_pending": { + "name": "idx_automation_logs_pending", + "columns": [ + "tenant_id", + "status", + "send_at" + ], + "isUnique": false + }, + "idx_automation_logs_insp": { + "name": "idx_automation_logs_insp", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "uq_automation_logs_event": { + "name": "uq_automation_logs_event", + "columns": [ + "automation_id", + "inspection_id", + "event_id", + "channel", + "recipient" + ], + "isUnique": true, + "where": "event_id IS NOT NULL" + } + }, + "foreignKeys": { + "automation_logs_tenant_id_tenants_id_fk": { + "name": "automation_logs_tenant_id_tenants_id_fk", + "tableFrom": "automation_logs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automations": { + "name": "automations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_kind": { + "name": "recipient_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_role_profile_id": { + "name": "recipient_role_profile_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delay_minutes": { + "name": "delay_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "subject_template": { + "name": "subject_template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body_template": { + "name": "body_template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_template_id": { + "name": "email_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "conditions": { + "name": "conditions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channels": { + "name": "channels", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"email\"]'" + }, + "sms_body": { + "name": "sms_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sms_template_id": { + "name": "sms_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "in_app_template_id": { + "name": "in_app_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automations_tenant": { + "name": "idx_automations_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automations_tenant_id_tenants_id_fk": { + "name": "automations_tenant_id_tenants_id_fk", + "tableFrom": "automations", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "availability": { + "name": "availability", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_availability_inspector": { + "name": "idx_availability_inspector", + "columns": [ + "inspector_id" + ], + "isUnique": false + }, + "idx_availability_window_unique": { + "name": "idx_availability_window_unique", + "columns": [ + "inspector_id", + "day_of_week", + "start_time" + ], + "isUnique": true + } + }, + "foreignKeys": { + "availability_tenant_id_tenants_id_fk": { + "name": "availability_tenant_id_tenants_id_fk", + "tableFrom": "availability", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "availability_inspector_id_users_id_fk": { + "name": "availability_inspector_id_users_id_fk", + "tableFrom": "availability", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "availability_overrides": { + "name": "availability_overrides", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_available": { + "name": "is_available", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "start_time": { + "name": "start_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transparency": { + "name": "transparency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_avail_overrides_insp": { + "name": "idx_avail_overrides_insp", + "columns": [ + "inspector_id" + ], + "isUnique": false + }, + "idx_avail_overrides_block_unique": { + "name": "idx_avail_overrides_block_unique", + "columns": [ + "inspector_id", + "date" + ], + "isUnique": true, + "where": "is_available = 0 AND source IS NULL" + }, + "uq_avail_overrides_external": { + "name": "uq_avail_overrides_external", + "columns": [ + "inspector_id", + "source", + "external_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "availability_overrides_tenant_id_tenants_id_fk": { + "name": "availability_overrides_tenant_id_tenants_id_fk", + "tableFrom": "availability_overrides", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "availability_overrides_inspector_id_users_id_fk": { + "name": "availability_overrides_inspector_id_users_id_fk", + "tableFrom": "availability_overrides", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "calendar_blocks": { + "name": "calendar_blocks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_all_day": { + "name": "is_all_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_calendar_blocks_tenant_user_date": { + "name": "idx_calendar_blocks_tenant_user_date", + "columns": [ + "tenant_id", + "user_id", + "date" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "calendar_connection_read_calendars": { + "name": "calendar_connection_read_calendars", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_calendar_id": { + "name": "external_calendar_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_role": { + "name": "access_role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_conn_read_cal": { + "name": "uq_conn_read_cal", + "columns": [ + "connection_id", + "external_calendar_id" + ], + "isUnique": true + }, + "idx_conn_read_cal_tenant": { + "name": "idx_conn_read_cal_tenant", + "columns": [ + "tenant_id", + "connection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "calendar_connections": { + "name": "calendar_connections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials_enc": { + "name": "credentials_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials_dek_enc": { + "name": "credentials_dek_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "calendar_id": { + "name": "calendar_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connected_at": { + "name": "connected_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_calendar_connections_user_provider": { + "name": "uq_calendar_connections_user_provider", + "columns": [ + "user_id", + "provider" + ], + "isUnique": true + }, + "idx_calendar_connections_tenant_user": { + "name": "idx_calendar_connections_tenant_user", + "columns": [ + "tenant_id", + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comment_usage": { + "name": "comment_usage", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_comment_usage_user_last_used": { + "name": "idx_comment_usage_user_last_used", + "columns": [ + "tenant_id", + "user_id", + "last_used_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "comment_usage_comment_id_comments_id_fk": { + "name": "comment_usage_comment_id_comments_id_fk", + "tableFrom": "comment_usage", + "tableTo": "comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "comment_usage_tenant_id_user_id_comment_id_pk": { + "columns": [ + "tenant_id", + "user_id", + "comment_id" + ], + "name": "comment_usage_tenant_id_user_id_comment_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rating_bucket": { + "name": "rating_bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section": { + "name": "section", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_ids": { + "name": "section_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_labels": { + "name": "item_labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger_code": { + "name": "trigger_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "search_keywords": { + "name": "search_keywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_label": { + "name": "item_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repair_summary": { + "name": "repair_summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimate_min_cents": { + "name": "estimate_min_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimate_max_cents": { + "name": "estimate_max_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recommended_contractor_type_id": { + "name": "recommended_contractor_type_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_comments_tenant": { + "name": "idx_comments_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_comments_rating_bucket": { + "name": "idx_comments_rating_bucket", + "columns": [ + "tenant_id", + "rating_bucket" + ], + "isUnique": false + }, + "idx_comments_library_id": { + "name": "idx_comments_library_id", + "columns": [ + "library_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "comments_tenant_id_tenants_id_fk": { + "name": "comments_tenant_id_tenants_id_fk", + "tableFrom": "comments", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commercial_subtypes": { + "name": "commercial_subtypes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "based_on": { + "name": "based_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_disabled": { + "name": "is_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_commercial_subtypes_tenant_name": { + "name": "idx_commercial_subtypes_tenant_name", + "columns": [ + "tenant_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "commercial_subtypes_tenant_id_tenants_id_fk": { + "name": "commercial_subtypes_tenant_id_tenants_id_fk", + "tableFrom": "commercial_subtypes", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "concierge_confirm_tokens": { + "name": "concierge_confirm_tokens", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_concierge_tokens_expiry": { + "name": "idx_concierge_tokens_expiry", + "columns": [ + "expires_at" + ], + "isUnique": false + }, + "idx_concierge_confirm_token_hash": { + "name": "idx_concierge_confirm_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "concierge_confirm_tokens_inspection_id_inspections_id_fk": { + "name": "concierge_confirm_tokens_inspection_id_inspections_id_fk", + "tableFrom": "concierge_confirm_tokens", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contact_role_profiles": { + "name": "contact_role_profiles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_template_id": { + "name": "email_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sms_template_id": { + "name": "sms_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capability_overrides": { + "name": "capability_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_crp_tenant": { + "name": "idx_crp_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_crp_tenant_key": { + "name": "uq_crp_tenant_key", + "columns": [ + "tenant_id", + "key" + ], + "isUnique": true, + "where": "is_active = 1" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contacts": { + "name": "contacts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'client'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agency": { + "name": "agency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_user_id": { + "name": "agent_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_linked_at": { + "name": "agent_linked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_revoked_at": { + "name": "agent_revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_contacts_type": { + "name": "idx_contacts_type", + "columns": [ + "tenant_id", + "type" + ], + "isUnique": false + }, + "idx_contacts_tenant": { + "name": "idx_contacts_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_contacts_tenant_email": { + "name": "uq_contacts_tenant_email", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": true, + "where": "email IS NOT NULL AND archived_at IS NULL" + }, + "uq_contacts_tenant_agent_user": { + "name": "uq_contacts_tenant_agent_user", + "columns": [ + "tenant_id", + "agent_user_id" + ], + "isUnique": true, + "where": "agent_user_id IS NOT NULL AND archived_at IS NULL" + }, + "idx_contacts_agent_user": { + "name": "idx_contacts_agent_user", + "columns": [ + "agent_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "contacts_tenant_id_tenants_id_fk": { + "name": "contacts_tenant_id_tenants_id_fk", + "tableFrom": "contacts", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contractor_types": { + "name": "contractor_types", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_contractor_types_tenant": { + "name": "idx_contractor_types_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cost_items": { + "name": "cost_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "building_id": { + "name": "building_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_index": { + "name": "instance_index", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit_id": { + "name": "unit_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finding_key": { + "name": "finding_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "system": { + "name": "system", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "component": { + "name": "component", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cost_method": { + "name": "cost_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uom": { + "name": "uom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit_cost_cents": { + "name": "unit_cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lump_sum_cents": { + "name": "lump_sum_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "eul": { + "name": "eul", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "eff_age": { + "name": "eff_age", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rul": { + "name": "rul", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suggested_remedy": { + "name": "suggested_remedy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_ref": { + "name": "section_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "photo_ref": { + "name": "photo_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_cost_items_tenant_inspection": { + "name": "idx_cost_items_tenant_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "idx_cost_items_finding_key": { + "name": "idx_cost_items_finding_key", + "columns": [ + "finding_key" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "defect_categories": { + "name": "defect_categories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#6b7280'" + }, + "is_summary_driver": { + "name": "is_summary_driver", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_seed": { + "name": "is_seed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_defect_categories_tenant": { + "name": "idx_defect_categories_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "discount_codes": { + "name": "discount_codes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uses_count": { + "name": "uses_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_discount_codes_tenant": { + "name": "idx_discount_codes_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_discount_codes_code_tenant": { + "name": "uq_discount_codes_code_tenant", + "columns": [ + "upper(code)", + "tenant_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "discount_codes_tenant_id_tenants_id_fk": { + "name": "discount_codes_tenant_id_tenants_id_fk", + "tableFrom": "discount_codes", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_review_items": { + "name": "document_review_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_key": { + "name": "document_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_requested": { + "name": "is_requested", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_received": { + "name": "is_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_reviewed": { + "name": "is_reviewed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_na": { + "name": "is_na", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_doc_review_inspection": { + "name": "idx_doc_review_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "uq_doc_review_item": { + "name": "uq_doc_review_item", + "columns": [ + "inspection_id", + "document_key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_suppressions": { + "name": "email_suppressions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_provider": { + "name": "source_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_event_id": { + "name": "provider_event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_email_suppressions_email": { + "name": "idx_email_suppressions_email", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "erasure_log": { + "name": "erasure_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_email": { + "name": "subject_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identity_basis": { + "name": "identity_basis", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decisions_json": { + "name": "decisions_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retained_count": { + "name": "retained_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "anonymized_count": { + "name": "anonymized_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "deleted_count": { + "name": "deleted_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_note": { + "name": "response_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_erasure_log_tenant": { + "name": "idx_erasure_log_tenant", + "columns": [ + "tenant_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "esign_audit_logs": { + "name": "esign_audit_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prev_hash": { + "name": "prev_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_fingerprint": { + "name": "key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_esign_audit_logs_request": { + "name": "idx_esign_audit_logs_request", + "columns": [ + "tenant_id", + "request_id", + "created_at" + ], + "isUnique": false + }, + "idx_esign_audit_logs_event_dedup": { + "name": "idx_esign_audit_logs_event_dedup", + "columns": [ + "tenant_id", + "request_id", + "event" + ], + "isUnique": true, + "where": "event NOT LIKE 'signer.%'" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_types": { + "name": "event_types", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_duration_min": { + "name": "default_duration_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "default_price_cents": { + "name": "default_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#6366f1'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_event_types_tenant_slug": { + "name": "uq_event_types_tenant_slug", + "columns": [ + "tenant_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_types_tenant_id_tenants_id_fk": { + "name": "event_types_tenant_id_tenants_id_fk", + "tableFrom": "event_types", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_access_tokens": { + "name": "inspection_access_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'client'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_enc": { + "name": "token_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_iat_token": { + "name": "idx_iat_token", + "columns": [ + "token" + ], + "isUnique": true + }, + "idx_iat_inspection": { + "name": "idx_iat_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "idx_iat_recipient": { + "name": "idx_iat_recipient", + "columns": [ + "inspection_id", + "recipient_email" + ], + "isUnique": true + }, + "idx_iat_token_hash": { + "name": "idx_iat_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "inspection_access_tokens_tenant_id_tenants_id_fk": { + "name": "inspection_access_tokens_tenant_id_tenants_id_fk", + "tableFrom": "inspection_access_tokens", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_events": { + "name": "inspection_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type_id": { + "name": "event_type_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_min": { + "name": "duration_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'scheduled'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "results_received_at": { + "name": "results_received_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gcal_event_id": { + "name": "gcal_event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_events_scheduled": { + "name": "idx_inspection_events_scheduled", + "columns": [ + "tenant_id", + "scheduled_at" + ], + "isUnique": false + }, + "idx_inspection_events_inspection": { + "name": "idx_inspection_events_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspection_events_tenant_id_tenants_id_fk": { + "name": "inspection_events_tenant_id_tenants_id_fk", + "tableFrom": "inspection_events", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_events_inspection_id_inspections_id_fk": { + "name": "inspection_events_inspection_id_inspections_id_fk", + "tableFrom": "inspection_events", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inspection_events_event_type_id_event_types_id_fk": { + "name": "inspection_events_event_type_id_event_types_id_fk", + "tableFrom": "inspection_events", + "tableTo": "event_types", + "columnsFrom": [ + "event_type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_events_inspector_id_users_id_fk": { + "name": "inspection_events_inspector_id_users_id_fk", + "tableFrom": "inspection_events", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_inspectors": { + "name": "inspection_inspectors", + "columns": { + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'lead'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_insp_inspectors_tenant_user": { + "name": "idx_insp_inspectors_tenant_user", + "columns": [ + "tenant_id", + "user_id" + ], + "isUnique": false + }, + "idx_insp_inspectors_user": { + "name": "idx_insp_inspectors_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "inspection_inspectors_inspection_id_user_id_pk": { + "columns": [ + "inspection_id", + "user_id" + ], + "name": "inspection_inspectors_inspection_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_item_tag_links": { + "name": "inspection_item_tag_links", + "columns": { + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_tag_links_tenant": { + "name": "idx_tag_links_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_tag_links_tag": { + "name": "idx_tag_links_tag", + "columns": [ + "tag_id" + ], + "isUnique": false + }, + "idx_tag_links_inspection_item": { + "name": "idx_tag_links_inspection_item", + "columns": [ + "inspection_id", + "item_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "inspection_item_tag_links_inspection_id_item_id_tag_id_pk": { + "columns": [ + "inspection_id", + "item_id", + "tag_id" + ], + "name": "inspection_item_tag_links_inspection_id_item_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_media_pool": { + "name": "inspection_media_pool", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exif_data": { + "name": "exif_data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "annotations": { + "name": "annotations", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'photo'" + }, + "stream_uid": { + "name": "stream_uid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "poster_pct": { + "name": "poster_pct", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_sec": { + "name": "duration_sec", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'stream'" + }, + "poster_key": { + "name": "poster_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_media_pool_tenant": { + "name": "idx_media_pool_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_media_pool_inspection": { + "name": "idx_media_pool_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_messages": { + "name": "inspection_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "from_role": { + "name": "from_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachments": { + "name": "attachments", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_user_id": { + "name": "from_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_msg_inspection": { + "name": "idx_msg_inspection", + "columns": [ + "inspection_id", + "created_at" + ], + "isUnique": false + }, + "idx_msg_contact": { + "name": "idx_msg_contact", + "columns": [ + "tenant_id", + "contact_id", + "created_at" + ], + "isUnique": false + }, + "idx_msg_unread": { + "name": "idx_msg_unread", + "columns": [ + "tenant_id", + "contact_id", + "from_role" + ], + "isUnique": false, + "where": "\"inspection_messages\".\"read_at\" IS NULL" + } + }, + "foreignKeys": { + "inspection_messages_tenant_id_tenants_id_fk": { + "name": "inspection_messages_tenant_id_tenants_id_fk", + "tableFrom": "inspection_messages", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_messages_inspection_id_inspections_id_fk": { + "name": "inspection_messages_inspection_id_inspections_id_fk", + "tableFrom": "inspection_messages", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_people": { + "name": "inspection_people", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_profile_id": { + "name": "role_profile_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_ip_inspection": { + "name": "idx_ip_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "idx_ip_tenant": { + "name": "idx_ip_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_ip_insp_contact_role": { + "name": "uq_ip_insp_contact_role", + "columns": [ + "inspection_id", + "contact_id", + "role_profile_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_requests": { + "name": "inspection_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_phone": { + "name": "client_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_address": { + "name": "property_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "property_city": { + "name": "property_city", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_state": { + "name": "property_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_zip": { + "name": "property_zip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_amount_cents": { + "name": "total_amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unpaid'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_requests_tenant": { + "name": "idx_inspection_requests_tenant", + "columns": [ + "tenant_id", + "status", + "scheduled_at" + ], + "isUnique": false + }, + "idx_inspection_requests_email": { + "name": "idx_inspection_requests_email", + "columns": [ + "tenant_id", + "client_email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspection_requests_tenant_id_tenants_id_fk": { + "name": "inspection_requests_tenant_id_tenants_id_fk", + "tableFrom": "inspection_requests", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_results": { + "name": "inspection_results", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ydoc_state": { + "name": "ydoc_state", + "type": "blob", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating_system_id": { + "name": "rating_system_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rating_system_snapshot": { + "name": "rating_system_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_results_tenant": { + "name": "idx_results_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_results_inspection": { + "name": "idx_results_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "uq_results_inspection": { + "name": "uq_results_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "inspection_results_tenant_id_tenants_id_fk": { + "name": "inspection_results_tenant_id_tenants_id_fk", + "tableFrom": "inspection_results", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_results_inspection_id_inspections_id_fk": { + "name": "inspection_results_inspection_id_inspections_id_fk", + "tableFrom": "inspection_results", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_services": { + "name": "inspection_services", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_id": { + "name": "service_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_override_cents": { + "name": "price_override_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name_snapshot": { + "name": "name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_snapshot_cents": { + "name": "price_snapshot_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_insp_services_tenant": { + "name": "idx_insp_services_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_insp_services_insp": { + "name": "idx_insp_services_insp", + "columns": [ + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspection_services_tenant_id_tenants_id_fk": { + "name": "inspection_services_tenant_id_tenants_id_fk", + "tableFrom": "inspection_services", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_services_inspection_id_inspections_id_fk": { + "name": "inspection_services_inspection_id_inspections_id_fk", + "tableFrom": "inspection_services", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inspection_services_service_id_services_id_fk": { + "name": "inspection_services_service_id_services_id_fk", + "tableFrom": "inspection_services", + "tableTo": "services", + "columnsFrom": [ + "service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_types": { + "name": "inspection_types", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "based_on": { + "name": "based_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_types_tenant_name": { + "name": "idx_inspection_types_tenant_name", + "columns": [ + "tenant_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_units": { + "name": "inspection_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_unit_id": { + "name": "parent_unit_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unit'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "attrs": { + "name": "attrs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_units_tenant_inspection": { + "name": "idx_inspection_units_tenant_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "idx_inspection_units_parent": { + "name": "idx_inspection_units_parent", + "columns": [ + "parent_unit_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspections": { + "name": "inspections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_address": { + "name": "property_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address_place_id": { + "name": "address_place_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_street": { + "name": "address_street", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_city": { + "name": "address_city", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_state": { + "name": "address_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_zip": { + "name": "address_zip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_county": { + "name": "address_county", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_lat": { + "name": "address_lat", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_lng": { + "name": "address_lng", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_geocoded_at": { + "name": "address_geocoded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'requested'" + }, + "report_status": { + "name": "report_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'in_progress'" + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unpaid'" + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancel_notes": { + "name": "cancel_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_payment_required": { + "name": "is_payment_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_agreement_required": { + "name": "is_agreement_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_auto_sign_on_publish": { + "name": "is_auto_sign_on_publish", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "discount_code_id": { + "name": "discount_code_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "discount_amount_cents": { + "name": "discount_amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "closing_date": { + "name": "closing_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referral_source": { + "name": "referral_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_number": { + "name": "reference_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "internal_notes": { + "name": "internal_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "year_built": { + "name": "year_built", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sqft": { + "name": "sqft", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "foundation_type": { + "name": "foundation_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bedrooms": { + "name": "bedrooms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bathrooms": { + "name": "bathrooms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lot_size": { + "name": "lot_size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_facts": { + "name": "property_facts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_photo_id": { + "name": "cover_photo_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_crop": { + "name": "cover_crop", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_image_key": { + "name": "cover_image_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commercial_subtype": { + "name": "commercial_subtype", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report_tier": { + "name": "report_tier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "county": { + "name": "county", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_automations_disabled": { + "name": "is_automations_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "template_snapshot": { + "name": "template_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_snapshot_version": { + "name": "template_snapshot_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "profile_override": { + "name": "profile_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "require_defect_fields_override": { + "name": "require_defect_fields_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "concierge_status": { + "name": "concierge_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_team_mode": { + "name": "is_team_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "lead_inspector_id": { + "name": "lead_inspector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "helper_inspector_ids": { + "name": "helper_inspector_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "data_version": { + "name": "data_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "source_inspection_id": { + "name": "source_inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "root_inspection_id": { + "name": "root_inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reinspection_round": { + "name": "reinspection_round", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit_inspection_mode": { + "name": "unit_inspection_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'tagged'" + }, + "location_options": { + "name": "location_options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sampling_declaration": { + "name": "sampling_declaration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pca_narrative": { + "name": "pca_narrative", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deviations": { + "name": "deviations", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report_photo_mode": { + "name": "report_photo_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_start_ms": { + "name": "scheduled_start_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_end_ms": { + "name": "scheduled_end_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_min": { + "name": "duration_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "badge_layout_override": { + "name": "badge_layout_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report_photo_columns": { + "name": "report_photo_columns", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referred_by_contact_id": { + "name": "referred_by_contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_inspections_tenant": { + "name": "idx_inspections_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_inspections_request": { + "name": "idx_inspections_request", + "columns": [ + "request_id" + ], + "isUnique": false + }, + "idx_inspections_inspector": { + "name": "idx_inspections_inspector", + "columns": [ + "inspector_id" + ], + "isUnique": false + }, + "idx_inspections_tenant_status": { + "name": "idx_inspections_tenant_status", + "columns": [ + "tenant_id", + "status" + ], + "isUnique": false + }, + "idx_inspections_tenant_date": { + "name": "idx_inspections_tenant_date", + "columns": [ + "tenant_id", + "date" + ], + "isUnique": false + }, + "idx_inspections_inspector_date": { + "name": "idx_inspections_inspector_date", + "columns": [ + "inspector_id", + "date" + ], + "isUnique": false + }, + "idx_inspections_root": { + "name": "idx_inspections_root", + "columns": [ + "root_inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspections_tenant_id_tenants_id_fk": { + "name": "inspections_tenant_id_tenants_id_fk", + "tableFrom": "inspections", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_inspector_id_users_id_fk": { + "name": "inspections_inspector_id_users_id_fk", + "tableFrom": "inspections", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_template_id_templates_id_fk": { + "name": "inspections_template_id_templates_id_fk", + "tableFrom": "inspections", + "tableTo": "templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_discount_code_id_discount_codes_id_fk": { + "name": "inspections_discount_code_id_discount_codes_id_fk", + "tableFrom": "inspections", + "tableTo": "discount_codes", + "columnsFrom": [ + "discount_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_request_id_inspection_requests_id_fk": { + "name": "inspections_request_id_inspection_requests_id_fk", + "tableFrom": "inspections", + "tableTo": "inspection_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspector_credentials": { + "name": "inspector_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "member_number": { + "name": "member_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_r2_key": { + "name": "image_r2_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspector_credentials_tenant": { + "name": "idx_inspector_credentials_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_inspector_credentials_user": { + "name": "idx_inspector_credentials_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invoices": { + "name": "invoices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "line_items": { + "name": "line_items", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_at": { + "name": "sent_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_at": { + "name": "paid_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method": { + "name": "payment_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "partial_paid_at": { + "name": "partial_paid_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voided_at": { + "name": "voided_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "qbo_sync_status": { + "name": "qbo_sync_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + } + }, + "indexes": { + "idx_invoices_tenant": { + "name": "idx_invoices_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_invoices_inspection": { + "name": "idx_invoices_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "idx_invoices_contact": { + "name": "idx_invoices_contact", + "columns": [ + "tenant_id", + "contact_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invoices_tenant_id_tenants_id_fk": { + "name": "invoices_tenant_id_tenants_id_fk", + "tableFrom": "invoices", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invoices_inspection_id_inspections_id_fk": { + "name": "invoices_inspection_id_inspections_id_fk", + "tableFrom": "invoices", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invoices_contact_id_contacts_id_fk": { + "name": "invoices_contact_id_contacts_id_fk", + "tableFrom": "invoices", + "tableTo": "contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "marketplace_libraries": { + "name": "marketplace_libraries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "changelog": { + "name": "changelog", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_count": { + "name": "download_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_featured": { + "name": "is_featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_marketplace_libraries_kind_featured": { + "name": "idx_marketplace_libraries_kind_featured", + "columns": [ + "kind", + "is_featured" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "marketplace_templates": { + "name": "marketplace_templates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "changelog": { + "name": "changelog", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_count": { + "name": "download_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_featured": { + "name": "is_featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "message_templates": { + "name": "message_templates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "variables": { + "name": "variables", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_seeded": { + "name": "is_seeded", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_message_templates_tenant_channel": { + "name": "idx_message_templates_tenant_channel", + "columns": [ + "tenant_id", + "channel" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messaging_compliance": { + "name": "messaging_compliance", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'own'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subaccount_sid": { + "name": "subaccount_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_profile_sid": { + "name": "customer_profile_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_profile_status": { + "name": "customer_profile_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "brand_sid": { + "name": "brand_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "brand_status": { + "name": "brand_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "campaign_sid": { + "name": "campaign_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "campaign_status": { + "name": "campaign_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tfv_sid": { + "name": "tfv_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tfv_status": { + "name": "tfv_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "messaging_resource_sid": { + "name": "messaging_resource_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_meta": { + "name": "provider_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provisioned_number": { + "name": "provisioned_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provisioned_number_sid": { + "name": "provisioned_number_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "has_sender_attached": { + "name": "has_sender_attached", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "compliance_status": { + "name": "compliance_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'not_started'" + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_preferences": { + "name": "notification_preferences", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_kind": { + "name": "subject_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "class_id": { + "name": "class_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_notification_prefs_unique": { + "name": "idx_notification_prefs_unique", + "columns": [ + "tenant_id", + "subject_kind", + "subject_id", + "class_id", + "channel" + ], + "isUnique": true + }, + "idx_notification_prefs_subject": { + "name": "idx_notification_prefs_subject", + "columns": [ + "tenant_id", + "subject_kind", + "subject_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "orphaned_media": { + "name": "orphaned_media", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_orphaned_media_key": { + "name": "idx_orphaned_media_key", + "columns": [ + "tenant_id", + "r2_key" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "processed_webhook_events": { + "name": "processed_webhook_events", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "psq_responses": { + "name": "psq_responses", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "responses": { + "name": "responses", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'sent'" + }, + "share_token": { + "name": "share_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_at": { + "name": "sent_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_psq_inspection": { + "name": "uq_psq_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": true + }, + "idx_psq_share_token": { + "name": "idx_psq_share_token", + "columns": [ + "share_token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "qbo_connections": { + "name": "qbo_connections", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "realm_id": { + "name": "realm_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_enc": { + "name": "refresh_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_sync_enabled": { + "name": "is_sync_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "default_item_id": { + "name": "default_item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "qbo_entity_map": { + "name": "qbo_entity_map", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_type": { + "name": "oi_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_id": { + "name": "oi_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qbo_type": { + "name": "qbo_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qbo_id": { + "name": "qbo_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qbo_sync_token": { + "name": "qbo_sync_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "synced_at": { + "name": "synced_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_qbo_entity_map_qbo": { + "name": "idx_qbo_entity_map_qbo", + "columns": [ + "tenant_id", + "qbo_type", + "qbo_id" + ], + "isUnique": true + }, + "idx_qbo_entity_map_oi": { + "name": "idx_qbo_entity_map_oi", + "columns": [ + "tenant_id", + "oi_type", + "oi_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "qbo_sync_errors": { + "name": "qbo_sync_errors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_type": { + "name": "oi_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_id": { + "name": "oi_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_msg": { + "name": "error_msg", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_resolved": { + "name": "is_resolved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rating_systems": { + "name": "rating_systems", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "levels": { + "name": "levels", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_seed": { + "name": "is_seed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_rating_systems_tenant_slug": { + "name": "idx_rating_systems_tenant_slug", + "columns": [ + "tenant_id", + "slug" + ], + "isUnique": true + }, + "idx_rating_systems_tenant": { + "name": "idx_rating_systems_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "rating_systems_tenant_id_tenants_id_fk": { + "name": "rating_systems_tenant_id_tenants_id_fk", + "tableFrom": "rating_systems", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repair_request_items": { + "name": "repair_request_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repair_request_id": { + "name": "repair_request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finding_key": { + "name": "finding_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_title": { + "name": "section_title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_label": { + "name": "item_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "comment_snapshot": { + "name": "comment_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requested_credit_cents": { + "name": "requested_credit_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "defect_title_snapshot": { + "name": "defect_title_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_snapshot": { + "name": "location_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_snapshot": { + "name": "category_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trade_snapshot": { + "name": "trade_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_repair_request_items_rr": { + "name": "idx_repair_request_items_rr", + "columns": [ + "repair_request_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repair_requests": { + "name": "repair_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_kind": { + "name": "created_by_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_ref": { + "name": "created_by_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "custom_intro": { + "name": "custom_intro", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "share_token": { + "name": "share_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_repair_requests_inspection": { + "name": "idx_repair_requests_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "idx_repair_requests_share_token": { + "name": "idx_repair_requests_share_token", + "columns": [ + "share_token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_exports": { + "name": "report_exports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_report_exports_inspection": { + "name": "idx_report_exports_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_pdfs": { + "name": "report_pdfs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rendered_at": { + "name": "rendered_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_version": { + "name": "source_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ready'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_report_pdfs_inspection_type": { + "name": "uq_report_pdfs_inspection_type", + "columns": [ + "inspection_id", + "type", + "version_number" + ], + "isUnique": true + }, + "idx_report_pdfs_tenant": { + "name": "idx_report_pdfs_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_report_pdfs_status": { + "name": "idx_report_pdfs_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_report_pdfs_content_hash": { + "name": "idx_report_pdfs_content_hash", + "columns": [ + "inspection_id", + "type", + "content_hash" + ], + "isUnique": false + } + }, + "foreignKeys": { + "report_pdfs_tenant_id_tenants_id_fk": { + "name": "report_pdfs_tenant_id_tenants_id_fk", + "tableFrom": "report_pdfs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_signoff": { + "name": "report_signoff", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "person_id": { + "name": "person_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "license": { + "name": "license", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "qualifications_ref": { + "name": "qualifications_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_at": { + "name": "signed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signature_ref": { + "name": "signature_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_dual_role": { + "name": "is_dual_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "idx_report_signoff_inspection": { + "name": "idx_report_signoff_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "uq_report_signoff_role": { + "name": "uq_report_signoff_role", + "columns": [ + "inspection_id", + "role" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_versions": { + "name": "report_versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_json": { + "name": "snapshot_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prev_hash": { + "name": "prev_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_fingerprint": { + "name": "key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_amendment": { + "name": "is_amendment", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "idx_report_versions_inspection": { + "name": "idx_report_versions_inspection", + "columns": [ + "inspection_id", + "version_number" + ], + "isUnique": false + }, + "uq_report_versions_inspection_version": { + "name": "uq_report_versions_inspection_version", + "columns": [ + "inspection_id", + "version_number" + ], + "isUnique": true + }, + "idx_report_versions_verify_token": { + "name": "idx_report_versions_verify_token", + "columns": [ + "verification_token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "service_inspectors": { + "name": "service_inspectors", + "columns": { + "service_id": { + "name": "service_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_service_inspectors_tenant": { + "name": "idx_service_inspectors_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "service_inspectors_service_id_user_id_pk": { + "columns": [ + "service_id", + "user_id" + ], + "name": "service_inspectors_service_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "services": { + "name": "services", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agreement_id": { + "name": "agreement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_services_tenant": { + "name": "idx_services_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "services_tenant_id_tenants_id_fk": { + "name": "services_tenant_id_tenants_id_fk", + "tableFrom": "services", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "services_template_id_templates_id_fk": { + "name": "services_template_id_templates_id_fk", + "tableFrom": "services", + "tableTo": "templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "services_agreement_id_agreements_id_fk": { + "name": "services_agreement_id_agreements_id_fk", + "tableFrom": "services", + "tableTo": "agreements", + "columnsFrom": [ + "agreement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "signing_keys": { + "name": "signing_keys", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key_enc": { + "name": "private_key_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key_iv": { + "name": "private_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Ed25519'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "signing_keys_tenant_id_tenants_id_fk": { + "name": "signing_keys_tenant_id_tenants_id_fk", + "tableFrom": "signing_keys", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sms_consent_log": { + "name": "sms_consent_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_type": { + "name": "recipient_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "disclosure_version": { + "name": "disclosure_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "captured_via": { + "name": "captured_via", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_sms_consent_contact": { + "name": "idx_sms_consent_contact", + "columns": [ + "tenant_id", + "contact_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sms_delivery_status": { + "name": "sms_delivery_status", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_sms_delivery_status_msg": { + "name": "idx_sms_delivery_status_msg", + "columns": [ + "tenant_id", + "provider_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sms_disclosure_versions": { + "name": "sms_disclosure_versions", + "columns": { + "version": { + "name": "version", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_seed": { + "name": "is_seed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_tags_tenant_name": { + "name": "idx_tags_tenant_name", + "columns": [ + "tenant_id", + "name" + ], + "isUnique": true + }, + "idx_tags_tenant": { + "name": "idx_tags_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "templates": { + "name": "templates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating_system_id": { + "name": "rating_system_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commercial_subtype": { + "name": "commercial_subtype", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_featured": { + "name": "is_featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_profile_id": { + "name": "default_profile_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_templates_tenant": { + "name": "idx_templates_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_templates_rating_system": { + "name": "idx_templates_rating_system", + "columns": [ + "rating_system_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "templates_tenant_id_tenants_id_fk": { + "name": "templates_tenant_id_tenants_id_fk", + "tableFrom": "templates", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_custom_holidays": { + "name": "tenant_custom_holidays", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_tenant_custom_holidays_tenant_date": { + "name": "uq_tenant_custom_holidays_tenant_date", + "columns": [ + "tenant_id", + "date" + ], + "isUnique": true + }, + "idx_tenant_custom_holidays_tenant_date": { + "name": "idx_tenant_custom_holidays_tenant_date", + "columns": [ + "tenant_id", + "date" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_library_imports": { + "name": "tenant_library_imports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_semver": { + "name": "imported_semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_at": { + "name": "imported_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "uq_tenant_library_import": { + "name": "uq_tenant_library_import", + "columns": [ + "tenant_id", + "library_id" + ], + "isUnique": true + }, + "idx_tenant_library_imports_tenant": { + "name": "idx_tenant_library_imports_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_marketplace_import_history": { + "name": "tenant_marketplace_import_history", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_version": { + "name": "source_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_version": { + "name": "target_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rows_affected": { + "name": "rows_affected", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_marketplace_history_tenant": { + "name": "idx_marketplace_history_tenant", + "columns": [ + "tenant_id", + "created_at" + ], + "isUnique": false + }, + "idx_marketplace_history_template": { + "name": "idx_marketplace_history_template", + "columns": [ + "template_id" + ], + "isUnique": false + }, + "idx_marketplace_history_library": { + "name": "idx_marketplace_history_library", + "columns": [ + "library_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_marketplace_imports": { + "name": "tenant_marketplace_imports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "marketplace_template_id": { + "name": "marketplace_template_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_semver": { + "name": "imported_semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_template_id": { + "name": "local_template_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_at": { + "name": "imported_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_mkt_imports_tmpl": { + "name": "idx_mkt_imports_tmpl", + "columns": [ + "marketplace_template_id" + ], + "isUnique": false + }, + "idx_mkt_imports_tenant": { + "name": "idx_mkt_imports_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk": { + "name": "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk", + "tableFrom": "tenant_marketplace_imports", + "tableTo": "marketplace_templates", + "columnsFrom": [ + "marketplace_template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tenant_marketplace_imports_local_template_id_templates_id_fk": { + "name": "tenant_marketplace_imports_local_template_id_templates_id_fk", + "tableFrom": "tenant_marketplace_imports", + "tableTo": "templates", + "columnsFrom": [ + "local_template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_counters": { + "name": "usage_counters", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period_key": { + "name": "period_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_usage_counters_tenant": { + "name": "idx_usage_counters_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_counters_tenant_id_metric_period_key_pk": { + "columns": [ + "tenant_id", + "metric", + "period_key" + ], + "name": "usage_counters_tenant_id_metric_period_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_templates": { + "name": "email_templates", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "blocks": { + "name": "blocks", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "email_templates_tenant_id_tenants_id_fk": { + "name": "email_templates_tenant_id_tenants_id_fk", + "tableFrom": "email_templates", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_templates_tenant_id_trigger_pk": { + "columns": [ + "tenant_id", + "trigger" + ], + "name": "email_templates_tenant_id_trigger_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_configs": { + "name": "tenant_configs", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "support_email": { + "name": "support_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_address": { + "name": "company_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_pdf_footer_shown": { + "name": "is_pdf_footer_shown", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_pdf_page_numbers_shown": { + "name": "is_pdf_page_numbers_shown", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_pdf_license_shown": { + "name": "is_pdf_license_shown", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sender_email": { + "name": "sender_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reply_to": { + "name": "reply_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_mode": { + "name": "email_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'platform'" + }, + "video_mode": { + "name": "video_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'r2'" + }, + "sms_mode": { + "name": "sms_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'platform'" + }, + "sender_display_name": { + "name": "sender_display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "point_of_contact": { + "name": "point_of_contact", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'company'" + }, + "billing_url": { + "name": "billing_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_url": { + "name": "review_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_phone": { + "name": "company_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "integration_config": { + "name": "integration_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secrets_enc": { + "name": "secrets_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dek_enc": { + "name": "dek_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ics_token": { + "name": "ics_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "widget_allowed_origins": { + "name": "widget_allowed_origins", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_profile_id": { + "name": "default_profile_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'signature'" + }, + "attention_thresholds": { + "name": "attention_thresholds", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"agreement_unsigned_h\":72,\"invoice_overdue_h\":72,\"report_unpublished_h\":72}'" + }, + "inspection_prefs": { + "name": "inspection_prefs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_estimates_shown": { + "name": "is_estimates_shown", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_repair_list_enabled": { + "name": "is_repair_list_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_customer_repair_export_enabled": { + "name": "is_customer_repair_export_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_unpaid_blocked": { + "name": "is_unpaid_blocked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_unsigned_agreement_blocked": { + "name": "is_unsigned_agreement_blocked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "custom_referral_sources": { + "name": "custom_referral_sources", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dashboard_column_prefs": { + "name": "dashboard_column_prefs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_concierge_review_required": { + "name": "is_concierge_review_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_inspector_choice_allowed": { + "name": "is_inspector_choice_allowed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_pdf_pipeline_enabled": { + "name": "is_pdf_pipeline_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_team_mode_default": { + "name": "is_team_mode_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_apprentice_review_required": { + "name": "is_apprentice_review_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_guest_invites_enabled": { + "name": "is_guest_invites_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "require_defect_fields": { + "name": "require_defect_fields", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "agreement_retention_years": { + "name": "agreement_retention_years", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 6 + }, + "reinspection_statuses": { + "name": "reinspection_statuses", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_collab_editing_enabled": { + "name": "is_collab_editing_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sms_byo_provider": { + "name": "sms_byo_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_byo_provider": { + "name": "email_byo_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'resend'" + }, + "is_managed_eligible": { + "name": "is_managed_eligible", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "managed_provider": { + "name": "managed_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'twilio'" + }, + "is_reserve_schedule_enabled": { + "name": "is_reserve_schedule_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "reserve_term_years": { + "name": "reserve_term_years", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 12 + }, + "inflation_rate_bps": { + "name": "inflation_rate_bps", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_timezone": { + "name": "default_timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'UTC'" + }, + "booking_slot_mode": { + "name": "booking_slot_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'fixed'" + }, + "booking_slot_interval_min": { + "name": "booking_slot_interval_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "holiday_region": { + "name": "holiday_region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "holiday_public_policy": { + "name": "holiday_public_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "holiday_internal_policy": { + "name": "holiday_internal_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'advisory'" + }, + "default_locale": { + "name": "default_locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en-US'" + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "is_archive_revoking_access": { + "name": "is_archive_revoking_access", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "legal_mode": { + "name": "legal_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'hosted'" + }, + "custom_privacy_url": { + "name": "custom_privacy_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_terms_url": { + "name": "custom_terms_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "privacy_body": { + "name": "privacy_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terms_body": { + "name": "terms_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_configs_tenant_id_tenants_id_fk": { + "name": "tenant_configs_tenant_id_tenants_id_fk", + "tableFrom": "tenant_configs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenants": { + "name": "tenants", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'free'" + }, + "stripe_connect_account_id": { + "name": "stripe_connect_account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "max_users": { + "name": "max_users", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 5 + }, + "deployment_mode": { + "name": "deployment_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'shared'" + }, + "applied_cmd_seq": { + "name": "applied_cmd_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "applied_cred_seq": { + "name": "applied_cred_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tenants_slug_unique": { + "name": "tenants_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_invites": { + "name": "tenant_invites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'inspector'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mentor_id": { + "name": "mentor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assigned_section_ids": { + "name": "assigned_section_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "permission_overrides": { + "name": "permission_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_invites_tenant": { + "name": "idx_invites_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_tenant_invites_pending_email": { + "name": "uq_tenant_invites_pending_email", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": true, + "where": "status = 'pending'" + } + }, + "foreignKeys": { + "tenant_invites_tenant_id_tenants_id_fk": { + "name": "tenant_invites_tenant_id_tenants_id_fk", + "tableFrom": "tenant_invites", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "license_number": { + "name": "license_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_signature_base64": { + "name": "default_signature_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_signature_enabled": { + "name": "is_signature_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manager'" + }, + "onboarding_state": { + "name": "onboarding_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_totp_enabled": { + "name": "is_totp_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "totp_recovery_codes": { + "name": "totp_recovery_codes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totp_verified_at": { + "name": "totp_verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mentor_id": { + "name": "mentor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assigned_section_ids": { + "name": "assigned_section_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terms_accepted": { + "name": "terms_accepted", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permission_overrides": { + "name": "permission_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + "deleted_at" + ], + "isUnique": false + }, + "uq_users_tenant_email": { + "name": "uq_users_tenant_email", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": true, + "where": "deleted_at IS NULL" + }, + "idx_users_tenant": { + "name": "idx_users_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_users_slug_per_tenant": { + "name": "idx_users_slug_per_tenant", + "columns": [ + "tenant_id", + "slug" + ], + "isUnique": true + }, + "idx_users_email": { + "name": "idx_users_email", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "users_tenant_id_tenants_id_fk": { + "name": "users_tenant_id_tenants_id_fk", + "tableFrom": "users", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_slug": { + "name": "inspector_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_audit_tenant_created": { + "name": "idx_audit_tenant_created", + "columns": [ + "tenant_id", + "created_at" + ], + "isUnique": false + }, + "idx_audit_entity": { + "name": "idx_audit_entity", + "columns": [ + "entity_type", + "entity_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_logs_tenant_id_tenants_id_fk": { + "name": "audit_logs_tenant_id_tenants_id_fk", + "tableFrom": "audit_logs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration_test_results": { + "name": "integration_test_results", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_ok": { + "name": "is_ok", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tested_by_user_id": { + "name": "tested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tested_at": { + "name": "tested_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_integration_test_tenant_target": { + "name": "idx_integration_test_tenant_target", + "columns": [ + "tenant_id", + "target", + "tested_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_notifications_tenant_user_created": { + "name": "idx_notifications_tenant_user_created", + "columns": [ + "tenant_id", + "user_id", + "created_at" + ], + "isUnique": false + }, + "idx_notifications_tenant_user_unread": { + "name": "idx_notifications_tenant_user_unread", + "columns": [ + "tenant_id", + "user_id", + "read_at" + ], + "isUnique": false + }, + "idx_notifications_tenant_contact_created": { + "name": "idx_notifications_tenant_contact_created", + "columns": [ + "tenant_id", + "contact_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notifications_tenant_id_tenants_id_fk": { + "name": "notifications_tenant_id_tenants_id_fk", + "tableFrom": "notifications", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "parked_cmd_events": { + "name": "parked_cmd_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "envelope": { + "name": "envelope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_parked_cmd_events_received_at": { + "name": "idx_parked_cmd_events_received_at", + "columns": [ + "received_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "processed_cmd_events": { + "name": "processed_cmd_events", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cmd_type": { + "name": "cmd_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "processed_at": { + "name": "processed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "slug_reservations": { + "name": "slug_reservations", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_outbox": { + "name": "sync_outbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_tried_at": { + "name": "last_tried_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_sync_outbox_status_created": { + "name": "idx_sync_outbox_status_created", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_destruction_records": { + "name": "tenant_destruction_records", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_slug": { + "name": "tenant_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rows_deleted": { + "name": "rows_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "r2_objects": { + "name": "r2_objects", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "r2_bytes": { + "name": "r2_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "kv_keys": { + "name": "kv_keys", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "destroyed_at": { + "name": "destroyed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_destruction_tenant": { + "name": "idx_destruction_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_destruction_destroyed_at": { + "name": "idx_destruction_destroyed_at", + "columns": [ + "destroyed_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "client_uploads": { + "name": "client_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_kind": { + "name": "uploaded_by_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_ref": { + "name": "uploaded_by_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_name": { + "name": "uploaded_by_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_client_uploads_inspection": { + "name": "idx_client_uploads_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "uq_discount_codes_code_tenant": { + "columns": { + "upper(code)": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 10ace5247..d10be30f6 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1785470983411, "tag": "0018_charming_wild_pack", "breakpoints": true + }, + { + "idx": 19, + "version": "6", + "when": 1785479812967, + "tag": "0019_motionless_dark_phoenix", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/api-types/index.ts b/packages/api-types/index.ts index 06f93361f..fd9c59df7 100644 --- a/packages/api-types/index.ts +++ b/packages/api-types/index.ts @@ -49,6 +49,12 @@ export type { NotificationsApi } from '../../server/api/notifications'; export type { PlacesApi } from '../../server/api/places'; export type { PortalApi } from '../../server/api/portal'; export type { PortalNoticesApi } from '../../server/api/portal/notices'; +// Notification preferences (§4) — three sibling routers, one per audience. +// Each needs its own per-module client for the same reason portalNotices does: +// they mount at a prefix another module already owns. +export type { NotificationPreferencesApi } from '../../server/api/notification-preferences'; +export type { AgentNotificationPreferencesApi } from '../../server/api/agent/notification-preferences'; +export type { PortalNotificationPreferencesApi } from '../../server/api/portal/notification-preferences'; export type { AgentNoticesApi } from '../../server/api/agent/notices'; export type { ProfileApi } from '../../server/api/profile'; export type { PublicShareApi } from '../../server/api/public-share'; diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 3a81667fd..265084cf2 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -16,8 +16,8 @@ "server/services/inspection/inspection-analytics.service.ts": 727, "app/routes/template-edit.tsx": 719, "server/api/admin/admin-settings.ts": 713, + "server/index.ts": 693, "app/components/media-studio/PhotoAnnotator.tsx": 692, - "server/index.ts": 690, "app/hooks/usePhotoOps.ts": 661, "server/lib/messaging/providers/telnyx-compliance.ts": 657, "app/components/editor/ItemEditor.tsx": 637, @@ -37,11 +37,11 @@ "app/components/NewInspectionWizard.tsx": 530, "server/api/inspections/media-studio.ts": 530, "server/services/portal-access.service.ts": 525, + "app/routes/settings-profile.tsx": 521, "server/api/inspections/publish.ts": 516, "server/api/portal.ts": 515, "app/components/settings/ManagedComplianceWizard.tsx": 514, "server/api/bookings/agreement.ts": 510, - "app/routes/settings-profile.tsx": 505, "server/services/inspection-request.service.ts": 501, "server/services/report-export-consumer.ts": 499, "server/api/repair-builder.ts": 497, @@ -60,6 +60,7 @@ "server/api/inspections/results.ts": 430, "app/hooks/useStructureEdit.ts": 424, "server/lib/middleware/di.ts": 417, + "app/routes/public/portal-inspection.tsx": 416, "app/routes/templates.tsx": 414, "app/routes/calendar.tsx": 410 } diff --git a/server/api/agent.ts b/server/api/agent.ts index ed1b1ca1d..31ad97d4c 100644 --- a/server/api/agent.ts +++ b/server/api/agent.ts @@ -25,6 +25,7 @@ import { import { withMcpMetadata } from "../lib/route-metadata-standards"; import { createApiResponseSchema } from '../lib/validations/shared.schema'; import agentPhotoRoutes from './agent/photo'; +import agentNotificationPreferenceRoutes from './agent/notification-preferences'; import { getDrizzle } from '../lib/route-helpers'; /** @@ -237,6 +238,9 @@ const inspectorsRoute = createRoute(withMcpMetadata({ const agentRoutes = createApiRouter() // Byte-serving lives in its own module (file-size ratchet on this one). .route('/', agentPhotoRoutes) + // Per-company notification preferences (§4) — see that module for why the + // agent cannot share the staff route. + .route('/', agentNotificationPreferenceRoutes) .openapi(getReportsRoute, async (c) => { // Move RBAC check inside to fix OpenAPIHono type inference issues with context await requireRole('manager')(c, async () => {}); @@ -345,9 +349,6 @@ const agentRoutes = createApiRouter() const patch: Parameters[1] = {}; if (body.slug !== undefined) patch.slug = body.slug; if (body.name !== undefined) patch.name = body.name; - if (body.notifyOnReferral !== undefined) patch.notifyOnReferral = body.notifyOnReferral; - if (body.notifyOnReport !== undefined) patch.notifyOnReport = body.notifyOnReport; - if (body.notifyOnPaid !== undefined) patch.notifyOnPaid = body.notifyOnPaid; if (body.timezone !== undefined) patch.timezone = body.timezone; await c.var.services.agent.updateProfile(user.sub, patch); diff --git a/server/api/agent/notification-preferences.ts b/server/api/agent/notification-preferences.ts new file mode 100644 index 000000000..cf6dce68d --- /dev/null +++ b/server/api/agent/notification-preferences.ts @@ -0,0 +1,166 @@ +import { createRoute, z } from '@hono/zod-openapi'; +import { createApiRouter } from '../../lib/openapi-router'; +import { requireRole } from '../../lib/middleware/rbac'; +import { withMcpMetadata } from '../../lib/route-metadata-standards'; +import { getDrizzle } from '../../lib/route-helpers'; +import { buildScreenModel } from '../../lib/notifications/screen-model'; +import { assertChoosable, readChoices, writeChoice } from '../../lib/notifications/preference-write'; +import { listAgentCompanies } from '../../services/agent/companies'; +import { Errors } from '../../lib/errors'; + +/** + * A partner agent's notification preferences — PER COMPANY (spec §4). + * + * An agent account is global (`users.tenant_id IS NULL`) and its JWT carries no + * tenant, so this cannot be the staff route with a different role check: there + * is no tenant in the session to scope a preference to. What the agent has + * instead is one `contacts` row per company that works with them, and that row + * is the subject a preference is keyed on. + * + * Per company, not global, because the relationships are genuinely separate: an + * agent who refers weekly to one firm and twice a year to another has a real + * reason to want different mail from each. The cost of that choice is that a + * company linked LATER starts at the defaults — which is why the screen lists + * the companies rather than hiding them behind one switch, and why `scope: + * 'all'` exists for the common case where the agent means "everyone". + * + * SUBJECT COMES FROM THE SESSION. The body names a COMPANY, never a subject: + * the contact id is looked up from the agent's own bindings, so naming another + * company is at worst a 400 and never a way to write someone else's row. + */ + +const ChannelSchema = z.enum(['email', 'sms', 'in_app']); + +const CompanySchema = z.object({ + id: z.string().describe('Tenant id of the inspection company.'), + name: z.string().describe('Company name as the agent knows it.'), +}); + +const ScreenResponseSchema = z.object({ + success: z.literal(true), + data: z.object({ + companies: z.array(CompanySchema), + selected: z.string().nullable().describe('Which company the returned rows describe.'), + alwaysSent: z.array(z.object({ + id: z.string(), label: z.string(), channels: z.array(z.string()), + })), + youChoose: z.array(z.object({ + id: z.string(), + label: z.string(), + channels: z.object({ email: z.string(), sms: z.string(), in_app: z.string() }), + })), + }), +}).openapi('AgentNotificationPreferencesScreen'); + +const SaveSchema = z.object({ + classId: z.string().describe('The notification class being changed, e.g. agent-new-referral.'), + channel: ChannelSchema.describe('Which channel this choice applies to.'), + enabled: z.boolean().describe('True to receive it; false to switch it off.'), + companyId: z.string().optional().describe('Tenant id to apply this to. Required unless scope is "all".'), + scope: z.enum(['company', 'all']).optional() + .describe('"all" applies the choice to every company currently linked to this agent.'), +}); + +const getScreenRoute = createRoute(withMcpMetadata({ + method: 'get', + path: '/notification-preferences', + tags: ['agents'], + summary: 'What each company sends this agent, and what they can switch off', + request: { + query: z.object({ + companyId: z.string().optional().describe('Which company to read. Defaults to the first.'), + }), + }, + responses: { + 200: { + content: { 'application/json': { schema: ScreenResponseSchema } }, + description: 'The agent\'s companies plus the two sections for the selected one.', + }, + 401: { description: 'Unauthorized' }, + }, + security: [{ bearerAuth: [] }], + operationId: 'getAgentNotificationPreferences', + description: + 'Lists the companies this agent is currently bound to and returns the notification ' + + 'screen for one of them. Channels a class never uses are reported as "unavailable", ' + + 'which is not the same as "off".', +}, { scopes: ['agent'], tier: 'extended' })); + +const saveRoute = createRoute(withMcpMetadata({ + method: 'put', + path: '/notification-preferences', + tags: ['agents'], + summary: 'Switch a notification on or off at one company', + request: { body: { content: { 'application/json': { schema: SaveSchema } } } }, + responses: { + 200: { + content: { 'application/json': { schema: z.object({ success: z.literal(true), applied: z.number() }) } }, + description: 'Saved. `applied` counts the companies it was written for.', + }, + 400: { description: 'Unknown class, a class that is always sent, or a company this agent is not bound to' }, + 401: { description: 'Unauthorized' }, + }, + security: [{ bearerAuth: [] }], + operationId: 'saveAgentNotificationPreference', + description: + 'Records one explicit choice against the agent\'s contact row at one company, or at ' + + 'every company currently linked to them when scope is "all". A choice that matches the ' + + 'class default deletes the row rather than storing it.', +}, { scopes: ['agent'], tier: 'extended' })); + +const agentNotificationPreferenceRoutes = createApiRouter() + .openapi(getScreenRoute, async (c) => { + await requireRole('agent')(c, async () => {}); + const agentUserId = c.get('user').sub; + const db = getDrizzle(c); + + const companies = await listAgentCompanies(db, agentUserId); + const { companyId } = c.req.valid('query'); + const selected = companyId + ? companies.find((x) => x.tenantId === companyId) + : companies[0]; + // An unknown id is not an error here: the agent may have been revoked + // since the page loaded, and a 400 on a READ would strand them on a + // screen with nothing to do. Fall back to showing the company list. + const chosen = selected + ? await readChoices(db, selected.tenantId, 'contact', selected.contactId) + : new Map(); + + return c.json({ + success: true as const, + data: { + companies: companies.map((x) => ({ id: x.tenantId, name: x.name })), + selected: selected?.tenantId ?? null, + ...buildScreenModel('agent', chosen), + }, + }, 200); + }) + .openapi(saveRoute, async (c) => { + await requireRole('agent')(c, async () => {}); + const agentUserId = c.get('user').sub; + const { classId, channel, enabled, companyId, scope } = c.req.valid('json'); + + // Refused at the edge as well as at the send boundary — the boundary is + // what makes a preference true, this is what keeps the screen honest. + assertChoosable(classId, channel, 'agent'); + + const db = getDrizzle(c); + const companies = await listAgentCompanies(db, agentUserId); + const targets = scope === 'all' + ? companies + : companies.filter((x) => x.tenantId === companyId); + if (targets.length === 0) { + throw Errors.BadRequest('You are not currently linked to that company.'); + } + + for (const t of targets) { + await writeChoice(db, { + tenantId: t.tenantId, subjectKind: 'contact', subjectId: t.contactId, + classId, channel, enabled, + }); + } + return c.json({ success: true as const, applied: targets.length }, 200); + }); + +export default agentNotificationPreferenceRoutes; +export type AgentNotificationPreferencesApi = typeof agentNotificationPreferenceRoutes; diff --git a/server/api/notification-preferences.ts b/server/api/notification-preferences.ts index efdf9fbc9..2ec12a12a 100644 --- a/server/api/notification-preferences.ts +++ b/server/api/notification-preferences.ts @@ -1,13 +1,9 @@ import { createRoute, z } from '@hono/zod-openapi'; -import { and, eq } from 'drizzle-orm'; -import { nanoid } from 'nanoid'; import { createApiRouter } from '../lib/openapi-router'; import { withMcpMetadata } from '../lib/route-metadata-standards'; import { getDrizzle } from '../lib/route-helpers'; -import { notificationPreferences } from '../lib/db/schema'; import { buildScreenModel } from '../lib/notifications/screen-model'; -import { isSuppressible, notificationClass } from '../lib/notifications/classes'; -import { Errors } from '../lib/errors'; +import { assertChoosable, readChoices, writeChoice } from '../lib/notifications/preference-write'; /** * The signed-in reader's own notification preferences (spec §4). @@ -17,9 +13,15 @@ import { Errors } from '../lib/errors'; * mute anyone. The route reads it from the JWT and the body carries only what * is being changed. * - * This is the STAFF/AGENT surface — an account holder, so the subject is a - * `users` row. The client portal has no account and authenticates by token; - * that surface resolves a `contacts` subject and is its own route. + * This is the STAFF surface — an account holder inside one company, so the + * subject is a `users` row and the tenant comes from the same JWT. + * + * The other two audiences cannot share it, and the reason is the same both + * times: they have no tenant-scoped `users` row to be the subject. A partner + * agent is a GLOBAL account (`users.tenant_id IS NULL`) whose JWT deliberately + * carries no tenant, so their preferences are per-company and keyed on the + * `contacts` row each company holds — `api/agent/notification-preferences.ts`. + * A client has no account at all and authenticates by token. */ const ChannelSchema = z.enum(['email', 'sms', 'in_app']); @@ -89,64 +91,25 @@ const notificationPreferenceRoutes = createApiRouter() const userId = c.get('user')?.sub as string; const db = getDrizzle(c); - const rows = await db.select({ - classId: notificationPreferences.classId, - channel: notificationPreferences.channel, - }).from(notificationPreferences) - .where(and( - eq(notificationPreferences.tenantId, tenantId), - eq(notificationPreferences.subjectKind, 'user'), - eq(notificationPreferences.subjectId, userId), - eq(notificationPreferences.enabled, false), - )).all(); - - // Only the MUTES are read. A row that restates the default would make - // the table grow with the user base instead of with the decisions (§3.2). - const muted = new Set(rows.map((r) => `${r.classId}:${r.channel}`)); - const audience = c.get('userRole') === 'agent' ? 'agent' : 'staff'; - return c.json({ success: true as const, data: buildScreenModel(audience, muted) }, 200); + // Only DIFFERENCES from the class default are stored, so this map stays + // small — a row that restates the default would make the table grow + // with the user base instead of with the decisions (§3.2). + const chosen = await readChoices(db, tenantId, 'user', userId); + return c.json({ success: true as const, data: buildScreenModel('staff', chosen) }, 200); }) .openapi(saveRoute, async (c) => { const tenantId = c.get('tenantId') as string; const userId = c.get('user')?.sub as string; const { classId, channel, enabled } = c.req.valid('json'); - const cls = notificationClass(classId); - if (!cls) throw Errors.BadRequest('Unknown notification.'); // Refused at the edge as well as at the send boundary. The boundary is // what makes it true; this is what makes it HONEST — a screen that // accepts the change and then ignores it is worse than one that says no. - if (!isSuppressible(classId)) throw Errors.BadRequest('This notification is always sent.'); - if (!cls.channels.includes(channel)) { - throw Errors.BadRequest('This notification is not sent on that channel.'); - } - // Same argument as the two refusals above: a class this reader is never - // addressed by cannot take effect for them, and the row would be one - // they could never see or clear — the screen does not render it. - const audience = c.get('userRole') === 'agent' ? 'agent' : 'staff'; - if (!cls.audience.includes(audience) || cls.recipientFacing === false) { - throw Errors.BadRequest('This notification is not addressed to you.'); - } - - const db = getDrizzle(c); - const where = and( - eq(notificationPreferences.tenantId, tenantId), - eq(notificationPreferences.subjectKind, 'user'), - eq(notificationPreferences.subjectId, userId), - eq(notificationPreferences.classId, classId), - eq(notificationPreferences.channel, channel), - ); + assertChoosable(classId, channel, 'staff'); - if (enabled) { - // Back to the default: delete rather than store `enabled = true`. - await db.delete(notificationPreferences).where(where).run(); - } else { - const now = new Date(); - await db.insert(notificationPreferences).values({ - id: nanoid(), tenantId, subjectKind: 'user', subjectId: userId, - classId, channel, enabled: false, createdAt: now, updatedAt: now, - }).onConflictDoNothing().run(); - } + await writeChoice(getDrizzle(c), { + tenantId, subjectKind: 'user', subjectId: userId, classId, channel, enabled, + }); return c.json({ success: true as const }, 200); }); diff --git a/server/api/portal/notification-preferences.ts b/server/api/portal/notification-preferences.ts new file mode 100644 index 000000000..61a94e7ae --- /dev/null +++ b/server/api/portal/notification-preferences.ts @@ -0,0 +1,148 @@ +import { createRoute, z } from '@hono/zod-openapi'; +import type { Context } from 'hono'; +import { createApiRouter } from '../../lib/openapi-router'; +import { withMcpMetadata } from '../../lib/route-metadata-standards'; +import { portalSessionGuard } from '../../lib/middleware/portal-session-guard'; +import { getDrizzle } from '../../lib/route-helpers'; +import type { HonoConfig } from '../../types/hono'; +import { buildScreenModel } from '../../lib/notifications/screen-model'; +import { assertChoosable, readChoices, writeChoice } from '../../lib/notifications/preference-write'; +import { contactIdsForEmail } from '../../services/notice-inbox'; +import { Errors } from '../../lib/errors'; + +/** + * The client's own notification settings, in the portal (spec §4.1). + * + * A client has no account, so the subject cannot be a `users` row and the + * identity cannot come from a JWT. It comes from the same place the Notices + * inbox gets it: a verified email in the `__Host-portal_session` cookie plus the + * tenant resolved from the path slug. The email is used for exactly one thing — + * resolving which `contacts` rows are this person here. + * + * ONE EMAIL CAN BE SEVERAL CONTACTS in a tenant (a repeat client booked twice, + * or the same person as client on one inspection and "other" on another). They + * are one human with one inbox, so a choice is written to EVERY one of their + * contact rows and a mute on ANY of them counts as off. Writing to just the + * first would produce a switch that works on some of the mail and not the rest, + * which is the kind of half-working control that is worse than none. + */ + +const TenantParam = z.object({ + tenant: z.string().describe('Tenant slug (resolves the tenant from the URL path).'), +}); + +const ScreenResponseSchema = z.object({ + success: z.literal(true), + data: z.object({ + alwaysSent: z.array(z.object({ + id: z.string(), label: z.string(), channels: z.array(z.string()), + })), + youChoose: z.array(z.object({ + id: z.string(), + label: z.string(), + channels: z.object({ email: z.string(), sms: z.string(), in_app: z.string() }), + })), + }), +}).openapi('PortalNotificationPreferencesScreen'); + +const SaveSchema = z.object({ + classId: z.string().describe('The notification class being changed, e.g. review-request.'), + channel: z.enum(['email', 'sms', 'in_app']).describe('Which channel this choice applies to.'), + enabled: z.boolean().describe('True to receive it again; false to switch it off.'), +}); + +function resolveTenantId(c: Context): string | null { + return c.get('tenantId') || c.get('resolvedTenantId') || null; +} + +const getScreenRoute = createRoute(withMcpMetadata({ + method: 'get', + path: '/{tenant}/notification-preferences', + tags: ['public'], + summary: 'What this company sends you, and what you can switch off', + request: { params: TenantParam }, + responses: { + 200: { + content: { 'application/json': { schema: ScreenResponseSchema } }, + description: 'The two sections spec §4 describes.', + }, + 401: { description: 'No valid portal session cookie' }, + 404: { description: 'Tenant slug not found' }, + }, + operationId: 'portalGetNotificationPreferences', + description: + 'Returns the notifications addressed to the signed-in recipient in this tenant, split ' + + 'into the ones that cannot be switched off and the ones they choose. Channels a class ' + + 'never uses are reported as "unavailable", which is not the same as "off".', +}, { scopes: [], tier: 'extended' })); + +const saveRoute = createRoute(withMcpMetadata({ + method: 'put', + path: '/{tenant}/notification-preferences', + tags: ['public'], + summary: 'Switch one notification on or off for one channel', + request: { + params: TenantParam, + body: { content: { 'application/json': { schema: SaveSchema } } }, + }, + responses: { + 200: { + content: { 'application/json': { schema: z.object({ success: z.literal(true) }) } }, + description: 'Saved.', + }, + 400: { description: 'Unknown class, or a class that cannot be switched off' }, + 401: { description: 'No valid portal session cookie' }, + }, + operationId: 'portalSaveNotificationPreference', + description: + 'Records one explicit choice against every contact row this session resolves to in this ' + + 'tenant. A choice that matches the class default deletes the row rather than storing it.', +}, { scopes: [], tier: 'extended' })); + +const router = createApiRouter(); +router.use('/:tenant/notification-preferences', portalSessionGuard); + +const portalNotificationPreferenceRoutes = router + .openapi(getScreenRoute, async (c) => { + const tenantId = resolveTenantId(c); + if (!tenantId) throw Errors.NotFound('Company not found.'); + const db = getDrizzle(c); + const contactIds = await contactIdsForEmail(db, tenantId, c.get('portalEmail') as string); + + // A mute on ANY of this person's contact rows counts. Merged with "off + // wins" rather than last-one-wins: the reader switched it off once and + // must not have to find the other row to make it stick. + const chosen = new Map(); + for (const id of contactIds) { + for (const [key, enabled] of await readChoices(db, tenantId, 'contact', id)) { + if (!chosen.has(key) || enabled === false) chosen.set(key, enabled); + } + } + return c.json({ success: true as const, data: buildScreenModel('client', chosen) }, 200); + }) + .openapi(saveRoute, async (c) => { + const tenantId = resolveTenantId(c); + if (!tenantId) throw Errors.NotFound('Company not found.'); + const { classId, channel, enabled } = c.req.valid('json'); + + // Refused at the edge as well as at the send boundary — the boundary is + // what makes a preference true, this is what keeps the screen honest. + assertChoosable(classId, channel, 'client'); + + const db = getDrizzle(c); + const contactIds = await contactIdsForEmail(db, tenantId, c.get('portalEmail') as string); + if (contactIds.length === 0) { + // A verified session with no contact row in this tenant. There is + // nowhere to put the choice and nothing that would read it. + throw Errors.BadRequest('There is nothing to change here.'); + } + for (const subjectId of contactIds) { + await writeChoice(db, { + tenantId, subjectKind: 'contact', subjectId, classId, channel, enabled, + }); + } + return c.json({ success: true as const }, 200); + }); + +export default portalNotificationPreferenceRoutes; +export type PortalNotificationPreferencesApi = typeof portalNotificationPreferenceRoutes; diff --git a/server/index.ts b/server/index.ts index 206091111..de751db4d 100644 --- a/server/index.ts +++ b/server/index.ts @@ -91,6 +91,7 @@ import inspectionRequestsRoutes from './api/inspection-requests'; import repairBuilderRoutes from './api/repair-builder'; import portalRoutes from './api/portal'; import portalNoticeRoutes from './api/portal/notices'; +import portalNotificationPreferenceRoutes from './api/portal/notification-preferences'; import tagsRoutes, { inspectionTagRoutes } from './api/tags'; import publicSlugRoutes from './api/public-slug'; import publicShareRoutes from './api/public-share'; @@ -359,6 +360,8 @@ const routes = app .route('/api/portal', portalRoutes) // C3 — the client's Notices inbox, its own module under the same prefix. .route('/api/portal', portalNoticeRoutes) + // The client's own notification settings (§4.1) — same portal-session auth. + .route('/api/portal', portalNotificationPreferenceRoutes) .route('/api/admin', adminRoutes) // Branding sub-router — extracted to fix hono/client type-collapse (C-10) .route('/api/admin', adminBrandingRoutes) diff --git a/server/lib/db/schema/tenant/user.ts b/server/lib/db/schema/tenant/user.ts index aecdb752c..37a4d099b 100644 --- a/server/lib/db/schema/tenant/user.ts +++ b/server/lib/db/schema/tenant/user.ts @@ -52,9 +52,6 @@ export const users = sqliteTable('users', { // inspector forwards the receipt manually if the agent wants visibility). // Read by EmailService.sendNewReferral / sendReportReady / sendInvoicePaid // before delivery; written from /agent-settings/profile (agent-side toggles). - notifyOnReferral: integer('is_referral_notification_enabled', { mode: 'boolean' }).notNull().default(true), - notifyOnReport: integer('is_report_notification_enabled', { mode: 'boolean' }).notNull().default(true), - notifyOnPaid: integer('is_paid_notification_enabled', { mode: 'boolean' }).notNull().default(false), // Design System 0520 subsystem B phase 1 — debounced "user last active" // timestamp updated by touch-last-active middleware (30s debounce window // per worker isolate). Powers TeamStrip "last active Nm ago" pill and the diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json index 09f6e3b61..aebbe1f45 100644 --- a/server/lib/mcp/openapi-snapshot.json +++ b/server/lib/mcp/openapi-snapshot.json @@ -6730,6 +6730,33 @@ "summary": "Address autocomplete proxy (public, rate-limited)", "description": "Auto-generated placeholder for geocodeBooking (GET /geocode, bookings domain). TODO: replace with a real description sourced from the handler." }, + { + "operationId": "getAgentNotificationPreferences", + "method": "GET", + "pathTemplate": "/api/agent/notification-preferences", + "scopes": [ + "agent" + ], + "tag": "agents", + "tier": "extended", + "inputSchema": { + "parameters": [ + { + "name": "companyId", + "in": "query", + "required": false, + "description": "Which company to read. Defaults to the first.", + "schema": { + "type": "string", + "description": "Which company to read. Defaults to the first." + } + } + ], + "body": null + }, + "summary": "What each company sends this agent, and what they can switch off", + "description": "Lists the companies this agent is currently bound to and returns the notification screen for one of them. Channels a class never uses are reported as \"unavailable\", which is not the same as \"off\"." + }, { "operationId": "getAgentProfile", "method": "GET", @@ -14416,6 +14443,31 @@ "summary": "Upgrade a per-inspection access token into a portal session", "description": "Exchanges a persistent per-(recipient, inspection) access token (the same family used by the public report links) for a __Host-portal_session cookie, so a client arriving from an email CTA lands in the portal already authenticated. Asserts the resolved grant tenant matches the path tenant AND the role currently grants selfRetrieveReport (client/co_client by default, and agent). SECURITY: an agent-kind role NEVER receives the session cookie — it gets `agent: true` and no Set-Cookie, so its report token can never unlock the client hub." }, + { + "operationId": "portalGetNotificationPreferences", + "method": "GET", + "pathTemplate": "/api/portal/{tenant}/notification-preferences", + "scopes": [], + "tag": "public", + "tier": "extended", + "inputSchema": { + "parameters": [ + { + "name": "tenant", + "in": "path", + "required": true, + "description": "Tenant slug (resolves the tenant from the URL path).", + "schema": { + "type": "string", + "description": "Tenant slug (resolves the tenant from the URL path)." + } + } + ], + "body": null + }, + "summary": "What this company sends you, and what you can switch off", + "description": "Returns the notifications addressed to the signed-in recipient in this tenant, split into the ones that cannot be switched off and the ones they choose. Channels a class never uses are reported as \"unavailable\", which is not the same as \"off\"." + }, { "operationId": "portalInspectionObserve", "method": "GET", @@ -14707,6 +14759,57 @@ "summary": "Request a portal magic-link by email", "description": "Requests a no-password magic-link for the unified client portal. ALWAYS returns 200 with { sent: true } regardless of whether the email has any access grant, to prevent account enumeration. When the email owns a live client/co_client access token in this tenant, an email containing a signed magic-link is sent." }, + { + "operationId": "portalSaveNotificationPreference", + "method": "PUT", + "pathTemplate": "/api/portal/{tenant}/notification-preferences", + "scopes": [], + "tag": "public", + "tier": "extended", + "inputSchema": { + "parameters": [ + { + "name": "tenant", + "in": "path", + "required": true, + "description": "Tenant slug (resolves the tenant from the URL path).", + "schema": { + "type": "string", + "description": "Tenant slug (resolves the tenant from the URL path)." + } + } + ], + "body": { + "type": "object", + "properties": { + "classId": { + "type": "string", + "description": "The notification class being changed, e.g. review-request." + }, + "channel": { + "type": "string", + "enum": [ + "email", + "sms", + "in_app" + ], + "description": "Which channel this choice applies to." + }, + "enabled": { + "type": "boolean", + "description": "True to receive it again; false to switch it off." + } + }, + "required": [ + "classId", + "channel", + "enabled" + ] + } + }, + "summary": "Switch one notification on or off for one channel", + "description": "Records one explicit choice against every contact row this session resolves to in this tenant. A choice that matches the class default deletes the row rather than storing it." + }, { "operationId": "postIntegrationSecrets", "method": "POST", @@ -16044,6 +16147,60 @@ "summary": "Revoke an OAuth grant by ID", "description": "Revokes an OAuth grant. Self-path revokes the caller's own grant; admin path (?admin=1) allows owner or manager to revoke any tenant member's grant with full audit logging." }, + { + "operationId": "saveAgentNotificationPreference", + "method": "PUT", + "pathTemplate": "/api/agent/notification-preferences", + "scopes": [ + "agent" + ], + "tag": "agents", + "tier": "extended", + "inputSchema": { + "parameters": [], + "body": { + "type": "object", + "properties": { + "classId": { + "type": "string", + "description": "The notification class being changed, e.g. agent-new-referral." + }, + "channel": { + "type": "string", + "enum": [ + "email", + "sms", + "in_app" + ], + "description": "Which channel this choice applies to." + }, + "enabled": { + "type": "boolean", + "description": "True to receive it; false to switch it off." + }, + "companyId": { + "type": "string", + "description": "Tenant id to apply this to. Required unless scope is \"all\"." + }, + "scope": { + "type": "string", + "enum": [ + "company", + "all" + ], + "description": "\"all\" applies the choice to every company currently linked to this agent." + } + }, + "required": [ + "classId", + "channel", + "enabled" + ] + } + }, + "summary": "Switch a notification on or off at one company", + "description": "Records one explicit choice against the agent's contact row at one company, or at every company currently linked to them when scope is \"all\". A choice that matches the class default deletes the row rather than storing it." + }, { "operationId": "saveEmailTemplate", "method": "PUT", diff --git a/server/lib/notifications/classes.ts b/server/lib/notifications/classes.ts index 7db100584..2e536ef79 100644 --- a/server/lib/notifications/classes.ts +++ b/server/lib/notifications/classes.ts @@ -64,6 +64,20 @@ export interface NotificationClass { * must be able to name what it is sending, and "nothing" is not an answer. */ recipientFacing?: boolean; + /** + * What "no row" means for this class. Default TRUE — we send unless told + * otherwise — and omitted everywhere it is. + * + * `false` exists because one notification was off by default before this + * table did: the agent invoice-paid mail. Without this field, migrating it + * had only bad answers — write a mute row for every user (the table then + * grows with the user base rather than with the decisions, §3.2), or drop + * the default and start sending mail nobody asked for. + * + * Absence still means "the class default"; this is what the class default + * IS. + */ + defaultEnabled?: boolean; /** * WHOSE screen this belongs on — §2's "Who" column, made executable. * @@ -134,11 +148,14 @@ export const NOTIFICATION_CLASSES: NotificationClass[] = [ { id: 'message-notification', label: 'New message from your inspector', category: 'transactional', required: false, channels: ['email', 'in_app'], audience: ['client'] }, { id: 'agent-share-link', label: 'Shared report link', category: 'transactional', required: false, channels: ['email'], audience: ['agent'] }, - // ─── agent notifications (spec §2.3) — already recipient-controlled today - // via notifyOnReferral / notifyOnReport / notifyOnPaid. + // ─── agent notifications (spec §2.3). These were three booleans on `users` + // with their own gate in the email service; the columns are retired and the + // send boundary is now the only place the choice is read. `agent-invoice-paid` + // carries `defaultEnabled: false` because that column defaulted to false — + // the default moved with the data rather than being quietly dropped. { id: 'agent-new-referral', label: 'A new referral is booked', category: 'transactional', required: false, channels: ['email'], audience: ['agent'] }, { id: 'agent-report-ready', label: 'A report is ready to read', category: 'transactional', required: false, channels: ['email'], audience: ['agent'] }, - { id: 'agent-invoice-paid', label: 'An invoice is paid', category: 'transactional', required: false, channels: ['email'], audience: ['agent'] }, + { id: 'agent-invoice-paid', label: 'An invoice is paid', category: 'transactional', required: false, channels: ['email'], audience: ['agent'], defaultEnabled: false }, // ─── automation rules the tenant did not write (spec §2.2, §2.3, §2.5) // @@ -212,6 +229,11 @@ export function notificationClass(id: string): NotificationClass | undefined { * The gate below makes "unknown" a build failure rather than a runtime one, but * the runtime default must still be the safe direction. */ +/** What "no preference row" means for this class. */ +export function defaultEnabled(id: string): boolean { + return BY_ID.get(id)?.defaultEnabled !== false; +} + export function isSuppressible(id: string): boolean { return BY_ID.get(id)?.required === false; } diff --git a/server/lib/notifications/preference-port.ts b/server/lib/notifications/preference-port.ts index f594ea2d1..d47a4f394 100644 --- a/server/lib/notifications/preference-port.ts +++ b/server/lib/notifications/preference-port.ts @@ -1,7 +1,7 @@ import { drizzle } from 'drizzle-orm/d1'; import { and, eq, inArray, or } from 'drizzle-orm'; import { contacts, notificationPreferences, users } from '../db/schema'; -import { isSuppressible } from './classes'; +import { defaultEnabled, isSuppressible } from './classes'; /** * The send-path preference port. `EmailService` asks it, per recipient, whether @@ -46,7 +46,8 @@ export async function isPreferenceMuted( subjects: PreferenceSubject[], ): Promise { if (!isSuppressible(classId)) return false; - if (subjects.length === 0) return false; + // No subject to consult: fall back to what the class itself defaults to. + if (subjects.length === 0) return !defaultEnabled(classId); const byKind = (kind: 'user' | 'contact') => subjects.filter((s) => s.kind === kind).map((s) => s.id); @@ -69,12 +70,13 @@ export async function isPreferenceMuted( eq(notificationPreferences.classId, classId), eq(notificationPreferences.channel, channel), match.length === 1 ? match[0] : or(...match), - eq(notificationPreferences.enabled, false), )) .get(); - // Absence is not "off": no row means the class default applies. - return !!row; + // Absence means the CLASS default, which is usually "send" but is not + // always — see `defaultEnabled`. An explicit row always wins over it. + if (!row) return !defaultEnabled(classId); + return row.enabled === false; } /** diff --git a/server/lib/notifications/preference-write.ts b/server/lib/notifications/preference-write.ts new file mode 100644 index 000000000..4b5a91580 --- /dev/null +++ b/server/lib/notifications/preference-write.ts @@ -0,0 +1,104 @@ +import { and, eq } from 'drizzle-orm'; +import { nanoid } from 'nanoid'; +import { notificationPreferences } from '../db/schema'; +import { defaultEnabled, isSuppressible, notificationClass, type Audience } from './classes'; +import { Errors } from '../errors'; + +/** + * What a preference screen is allowed to write, and how. + * + * Two surfaces write these rows — the account screen (staff and agent) and the + * client portal — and a third will exist the moment someone adds one. The + * refusals below are what keep the SCREEN honest: the send boundary is what + * makes a preference true, so a route that accepts a change the boundary would + * ignore is worse than one that says no. Duplicating four `if`s per surface is + * how the two would come to disagree, and the disagreement would be invisible + * — nobody reports mail they did not receive. + */ + +export interface PreferenceWrite { + tenantId: string; + subjectKind: 'user' | 'contact'; + subjectId: string; + classId: string; + channel: 'email' | 'sms' | 'in_app'; + enabled: boolean; +} + +/** + * Refuse anything the send boundary would not honour, for this reader. + * + * Ordering is deliberate: unknown class first (nothing else can be checked + * without it), then required, then the channel, then the audience. Each throws + * a 400 with a sentence a reader could act on rather than a code. + */ +export function assertChoosable(classId: string, channel: string, audience: Audience): void { + const cls = notificationClass(classId); + if (!cls) throw Errors.BadRequest('Unknown notification.'); + if (!isSuppressible(classId)) throw Errors.BadRequest('This notification is always sent.'); + if (!cls.channels.includes(channel as 'email' | 'sms' | 'in_app')) { + throw Errors.BadRequest('This notification is not sent on that channel.'); + } + // A class this reader is never addressed by cannot take effect for them, and + // the row would be one they can neither see nor clear — nothing renders it. + if (!cls.audience.includes(audience) || cls.recipientFacing === false) { + throw Errors.BadRequest('This notification is not addressed to you.'); + } +} + +/** + * Persist one choice. + * + * STORE ONLY WHAT DIFFERS FROM THE CLASS DEFAULT; matching it deletes the row. + * Stated that way rather than as "delete on enable" because one class defaults + * to OFF (`agent-invoice-paid`, whose column defaulted to false and whose + * default moved across with the data), and there the row is what expresses + * "yes, send me this". The consequence is §3.2's: the table grows with the + * decisions people make, not with the number of people. + */ +export async function writeChoice( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + db: any, + w: PreferenceWrite, +): Promise { + const where = and( + eq(notificationPreferences.tenantId, w.tenantId), + eq(notificationPreferences.subjectKind, w.subjectKind), + eq(notificationPreferences.subjectId, w.subjectId), + eq(notificationPreferences.classId, w.classId), + eq(notificationPreferences.channel, w.channel), + ); + + // Unconditional delete first, so a repeated mute leaves one row rather than + // a second one the unique index would have to catch. + await db.delete(notificationPreferences).where(where).run(); + if (w.enabled === defaultEnabled(w.classId)) return; + + const now = new Date(); + await db.insert(notificationPreferences).values({ + id: nanoid(), tenantId: w.tenantId, subjectKind: w.subjectKind, subjectId: w.subjectId, + classId: w.classId, channel: w.channel, enabled: w.enabled, createdAt: now, updatedAt: now, + }).run(); +} + +/** The explicit choices one subject holds, as `${classId}:${channel}` → enabled. */ +export async function readChoices( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + db: any, + tenantId: string, + subjectKind: 'user' | 'contact', + subjectId: string, +): Promise> { + const rows = await db.select({ + classId: notificationPreferences.classId, + channel: notificationPreferences.channel, + enabled: notificationPreferences.enabled, + }).from(notificationPreferences) + .where(and( + eq(notificationPreferences.tenantId, tenantId), + eq(notificationPreferences.subjectKind, subjectKind), + eq(notificationPreferences.subjectId, subjectId), + )).all(); + return new Map(rows.map((r: { classId: string; channel: string; enabled: boolean }) => + [`${r.classId}:${r.channel}`, r.enabled])); +} diff --git a/server/lib/notifications/screen-model.ts b/server/lib/notifications/screen-model.ts index 2d9d7c04d..ea5af3758 100644 --- a/server/lib/notifications/screen-model.ts +++ b/server/lib/notifications/screen-model.ts @@ -1,4 +1,4 @@ -import { NOTIFICATION_CLASSES, type Audience, type NotificationClass } from './classes'; +import { NOTIFICATION_CLASSES, defaultEnabled, type Audience, type NotificationClass } from './classes'; /** * What one reader sees on the notifications screen (spec §4). @@ -46,12 +46,13 @@ export function classesFor(audience: Audience): NotificationClass[] { } /** - * @param muted `${classId}:${channel}` for every explicit `enabled = false` - * row this subject holds. ABSENCE IS NOT "OFF" — a class with no - * row is on, which is why this takes the mutes rather than the - * full preference set. + * @param chosen `${classId}:${channel}` → the explicit choice this subject + * stored, for the rows they actually hold. A class with NO entry + * falls back to its own default, which is usually "send" but is + * not always — see `defaultEnabled`. Only differences from the + * default are stored, so this map stays small (§3.2). */ -export function buildScreenModel(audience: Audience, muted: ReadonlySet): ScreenModel { +export function buildScreenModel(audience: Audience, chosen: ReadonlyMap): ScreenModel { const visible = classesFor(audience); return { alwaysSent: visible @@ -65,7 +66,7 @@ export function buildScreenModel(audience: Audience, muted: ReadonlySet) channels: Object.fromEntries(CHANNELS.map((ch) => [ ch, !c.channels.includes(ch) ? 'unavailable' - : muted.has(`${c.id}:${ch}`) ? 'off' : 'on', + : (chosen.get(`${c.id}:${ch}`) ?? defaultEnabled(c.id)) ? 'on' : 'off', ])) as ScreenRow['channels'], })), }; diff --git a/server/lib/validations/agent.schema.ts b/server/lib/validations/agent.schema.ts index 16769ce68..57f38f89d 100644 --- a/server/lib/validations/agent.schema.ts +++ b/server/lib/validations/agent.schema.ts @@ -43,9 +43,6 @@ export const LeaderboardResponseSchema = createApiResponseSchema( export const AgentProfilePatchSchema = z.object({ slug: z.string().min(3).max(32).regex(/^[a-z0-9][a-z0-9-]+[a-z0-9]$/).optional().describe('TODO describe slug field for the OpenInspection MCP integration'), name: z.string().min(1).max(120).optional().describe('TODO describe name field for the OpenInspection MCP integration'), - notifyOnReferral: z.boolean().optional().describe('TODO describe notifyOnReferral field for the OpenInspection MCP integration'), - notifyOnReport: z.boolean().optional().describe('TODO describe notifyOnReport field for the OpenInspection MCP integration'), - notifyOnPaid: z.boolean().optional().describe('TODO describe notifyOnPaid field for the OpenInspection MCP integration'), // Personal display-timezone override (IANA id). Empty string clears it, so // referral dates fall back to each inspecting company's timezone. Validated // against the runtime Intl database in the service (isValidTimeZone). @@ -66,9 +63,6 @@ export const AgentProfileResponseSchema = createApiResponseSchema( name: z.string().nullable().describe('TODO describe name field for the OpenInspection MCP integration'), email: z.string().describe('TODO describe email field for the OpenInspection MCP integration'), slug: z.string().nullable().describe('TODO describe slug field for the OpenInspection MCP integration'), - notifyOnReferral: z.boolean().describe('TODO describe notifyOnReferral field for the OpenInspection MCP integration'), - notifyOnReport: z.boolean().describe('TODO describe notifyOnReport field for the OpenInspection MCP integration'), - notifyOnPaid: z.boolean().describe('TODO describe notifyOnPaid field for the OpenInspection MCP integration'), timezone: z.string().nullable().describe('Personal display timezone (IANA id), or null to use each company timezone.'), }), ).openapi('AgentProfileResponse'); diff --git a/server/services/agent/companies.ts b/server/services/agent/companies.ts new file mode 100644 index 000000000..3c6556c16 --- /dev/null +++ b/server/services/agent/companies.ts @@ -0,0 +1,47 @@ +import { and, asc, eq, isNull } from 'drizzle-orm'; +import { contacts } from '../../lib/db/schema/contact'; +import { tenants } from '../../lib/db/schema/tenant'; + +/** One company a partner agent currently works with, and their identity there. */ +export interface AgentCompany { + tenantId: string; + /** The `contacts` row this company holds for the agent — the preference subject. */ + contactId: string; + name: string; +} + +/** + * The companies a partner agent is currently bound to. + * + * The predicate is the same one the referral reader uses for access + * (`services/agent/referral.ts`): the binding lives on the contact + * (`agent_user_id`, IA-104) and a revoked binding is stamped rather than + * cleared, so `agent_revoked_at IS NULL` is what makes it current. Reusing the + * predicate rather than restating it is deliberate — a screen that listed a + * company the agent can no longer see would offer a control with nothing behind + * it, and a revoked agent must not keep steering that company's sends. + */ +export async function listAgentCompanies( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + db: any, + agentUserId: string, +): Promise { + const rows = await db.select({ + tenantId: contacts.tenantId, + contactId: contacts.id, + name: tenants.name, + }).from(contacts) + .innerJoin(tenants, eq(tenants.id, contacts.tenantId)) + .where(and( + eq(contacts.agentUserId, agentUserId), + isNull(contacts.agentRevokedAt), + )) + .orderBy(asc(tenants.name)) + .all(); + + return rows.map((r: { tenantId: string; contactId: string; name: string | null }) => ({ + tenantId: r.tenantId, + contactId: r.contactId, + name: r.name ?? r.tenantId, + })); +} diff --git a/server/services/agent/profile.ts b/server/services/agent/profile.ts index 78bdc016d..4a209a03a 100644 --- a/server/services/agent/profile.ts +++ b/server/services/agent/profile.ts @@ -8,9 +8,6 @@ import { logger } from '../../lib/logger'; export interface AgentProfilePatch { slug?: string; - notifyOnReferral?: boolean; - notifyOnReport?: boolean; - notifyOnPaid?: boolean; name?: string; /** Personal display-timezone override (IANA id). '' clears it (referral * dates then follow each inspecting company's timezone). */ @@ -49,9 +46,6 @@ export async function updateProfile( const set: Record = {}; if (patch.slug !== undefined) set.slug = patch.slug.trim().toLowerCase(); - if (patch.notifyOnReferral !== undefined) set.notifyOnReferral = patch.notifyOnReferral; - if (patch.notifyOnReport !== undefined) set.notifyOnReport = patch.notifyOnReport; - if (patch.notifyOnPaid !== undefined) set.notifyOnPaid = patch.notifyOnPaid; if (patch.name !== undefined) set.name = patch.name; if (patch.timezone !== undefined) { // Empty string clears the override (NULL = follow each company's tz). @@ -77,13 +71,11 @@ export async function getProfile(rawDb: D1Database, userId: string) { const db = drizzle(rawDb); const row = await db.select({ name: users.name, email: users.email, slug: users.slug, - notifyOnReferral: users.notifyOnReferral, notifyOnReport: users.notifyOnReport, notifyOnPaid: users.notifyOnPaid, timezone: users.timezone, }).from(users).where(eq(users.id, userId)).get(); if (!row) throw Errors.NotFound('Agent profile not found'); return { name: row.name ?? null, email: row.email ?? '', slug: row.slug ?? null, - notifyOnReferral: !!row.notifyOnReferral, notifyOnReport: !!row.notifyOnReport, notifyOnPaid: !!row.notifyOnPaid, timezone: row.timezone ?? null, }; } diff --git a/server/services/email/agent.ts b/server/services/email/agent.ts index de4558023..3bc4274ae 100644 --- a/server/services/email/agent.ts +++ b/server/services/email/agent.ts @@ -1,4 +1,3 @@ -import { logger } from '../../lib/logger'; import type { SignatureUser } from '../../lib/inspector-signature'; import { escapeHtml, type Constructor } from './base'; @@ -114,17 +113,13 @@ export function AgentEmailMixin(Base: TBase) { /** * Agent Accounts A2 — notify a partner agent that a new inspection has - * been booked under their referral. Gated on `agent.notifyOnReferral`; + * been booked under their referral. Muted by the recipient's own preference at the send boundary; * when the flag is false the call is a silent no-op (logged). */ async sendNewReferral( - agent: { id: string; email: string; name: string | null; notifyOnReferral: boolean }, + agent: { id: string; email: string; name: string | null }, params: { propertyAddress: string; clientName: string | null; dashboardUrl: string }, ): Promise { - if (!agent.notifyOnReferral) { - logger.debug('email.sendNewReferral.skipped', { agentId: agent.id, reason: 'preference_off' }); - return; - } const greet = agent.name ? `Hi ${agent.name},` : 'Hi,'; const client = params.clientName ? ` for ${params.clientName}` : ''; const fallbackBody = `
@@ -145,18 +140,14 @@ export function AgentEmailMixin(Base: TBase) { /** * Agent Accounts A2 — agent-recipient variant of sendReportReady. Gated - * on `agent.notifyOnReport`. Distinct from the inspector-issued + * by the recipient's preference at the send boundary. Distinct from the inspector-issued * `sendReportReady` (client recipient) so the gating logic stays scoped * to the agent path. */ async sendAgentReportReady( - agent: { id: string; email: string; name: string | null; notifyOnReport: boolean }, + agent: { id: string; email: string; name: string | null }, params: { propertyAddress: string; reportUrl: string }, ): Promise { - if (!agent.notifyOnReport) { - logger.debug('email.sendAgentReportReady.skipped', { agentId: agent.id, reason: 'preference_off' }); - return; - } const greet = agent.name ? `Hi ${agent.name},` : 'Hi,'; const fallbackBody = `

Report ready to read

@@ -176,17 +167,13 @@ export function AgentEmailMixin(Base: TBase) { /** * Agent Accounts A2 — notify a partner agent that an invoice on one of - * their referrals has been paid. Gated on `agent.notifyOnPaid` (off by + * their referrals has been paid. Off by * default — high-noise signal that most agents won't want). */ async sendInvoicePaid( - agent: { id: string; email: string; name: string | null; notifyOnPaid: boolean }, + agent: { id: string; email: string; name: string | null }, params: { propertyAddress: string; amountCents: number }, ): Promise { - if (!agent.notifyOnPaid) { - logger.debug('email.sendInvoicePaid.skipped', { agentId: agent.id, reason: 'preference_off' }); - return; - } const dollars = (params.amountCents / 100).toFixed(2); const greet = agent.name ? `Hi ${agent.name},` : 'Hi,'; const fallbackBody = `
diff --git a/tests/unit/agent/profile-get.spec.ts b/tests/unit/agent/profile-get.spec.ts index 002cfab21..084d2486a 100644 --- a/tests/unit/agent/profile-get.spec.ts +++ b/tests/unit/agent/profile-get.spec.ts @@ -28,19 +28,20 @@ describe('getProfile', () => { await f.db.insert(schema.users).values({ id: 'ag1', tenantId: null, email: 'jane@x.com', role: 'agent', name: 'Jane', - slug: 'jane', notifyOnReferral: true, notifyOnReport: true, notifyOnPaid: false, - passwordHash: 'H', createdAt: new Date(), + slug: 'jane', passwordHash: 'H', createdAt: new Date(), } as any); // eslint-disable-line @typescript-eslint/no-explicit-any }); afterEach(() => f.sqlite.close()); it('returns the agent profile shape', async () => { + // The three notifyOn* fields left this payload when preferences moved to + // `notification_preferences`. A profile that still carried them would be + // a second place to answer the same question — which is how the agent + // screen and the send path came apart in the first place. const p = await getProfile(rawDb, 'ag1'); expect(p).toEqual({ - name: 'Jane', email: 'jane@x.com', slug: 'jane', - notifyOnReferral: true, notifyOnReport: true, notifyOnPaid: false, - timezone: null, + name: 'Jane', email: 'jane@x.com', slug: 'jane', timezone: null, }); }); diff --git a/tests/unit/agents/agent-notification-prefs-schema.spec.ts b/tests/unit/agents/agent-notification-prefs-schema.spec.ts index 38b0fbfb5..f2b769787 100644 --- a/tests/unit/agents/agent-notification-prefs-schema.spec.ts +++ b/tests/unit/agents/agent-notification-prefs-schema.spec.ts @@ -1,11 +1,36 @@ +/** + * The three per-event columns on `users` are RETIRED, and this is the guard + * against them coming back. + * + * They were `is_referral_notification_enabled`, `is_report_notification_enabled` + * and `is_paid_notification_enabled` — one boolean per event, read by one send + * method each. That shape is the reason the agent screen and the send path could + * disagree: adding a fourth agent notification meant adding a fourth column, and + * the ones nobody added a column for simply had no off switch. Preferences now + * live in `notification_preferences`, keyed by class and channel, so a new class + * arrives with its control already working. + * + * The failure this pins is not "someone re-adds these exact names" — it is the + * cheaper mistake of answering the same question in two places, which is + * invisible until a recipient gets mail they switched off. Enforcement itself is + * covered by `notifications/preference-enforcement.spec.ts`, and the screen by + * `notifications/preferences-api.spec.ts`. + */ import { describe, it, expect } from 'vitest'; import { users } from '../../../server/lib/db/schema/tenant'; -describe('users — A2 notification prefs schema', () => { - it('declares is_referral_notification_enabled, is_report_notification_enabled, is_paid_notification_enabled', () => { - const t = users as unknown as Record; - expect(t.notifyOnReferral?.name).toBe('is_referral_notification_enabled'); - expect(t.notifyOnReport?.name).toBe('is_report_notification_enabled'); - expect(t.notifyOnPaid?.name).toBe('is_paid_notification_enabled'); +describe('users — retired per-event notification columns', () => { + it('declares no per-event notification booleans', () => { + const columns = Object.values(users as unknown as Record) + .map((c) => (typeof c?.name === 'string' ? c.name : '')) + .filter(Boolean); + + // A scan that sees nothing would pass this on an empty list, which is + // the exact way a gate lies about what it checked. Prove it is reading + // real column names before trusting the absence of the retired ones. + expect(columns).toContain('email'); + + const perEvent = columns.filter((n) => /_notification_enabled$/.test(n)); + expect(perEvent).toEqual([]); }); }); diff --git a/tests/unit/agents/agent-notification-prefs.spec.ts b/tests/unit/agents/agent-notification-prefs.spec.ts index ce80f30f3..87cb767e7 100644 --- a/tests/unit/agents/agent-notification-prefs.spec.ts +++ b/tests/unit/agents/agent-notification-prefs.spec.ts @@ -1,87 +1,170 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +/** + * The agent's three notifications, gated where every notification is gated. + * + * This file used to assert that `sendNewReferral` read `agent.notifyOnReferral` + * and returned early. That guarantee still holds — an agent who switches + * referral mail off does not get referral mail — but it is no longer the send + * METHOD's job, and pinning it there was pinning the wrong thing: three methods + * each carried their own copy of the check, and the ~45 notifications with no + * column simply had no off switch at all. + * + * So the assertions below sit on the far side of the boundary: a real row in + * `notification_preferences`, the real port, and what actually reached the + * PROVIDER. That also covers the part the old flags could not express — the + * class id the boundary gates on is derived from the same trigger that rendered + * the body, so a method cannot render one notification and be gated as another. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createTestDb, setupSchema, toRawD1 } from '../db'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import * as schema from '../../../server/lib/db/schema'; + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; + +// eslint-disable-next-line import/order import { EmailService } from '../../../server/services/email.service'; +// eslint-disable-next-line import/order +import { buildNotificationPreferences } from '../../../server/lib/notifications/preference-port'; + +const TENANT = 't-agent-prefs'; +const AGENT_EMAIL = 'jane@realty.com'; +const AGENT_ID = 'ag1'; +/** The agent's `contacts` row in this tenant — see `seedAgent`. */ +const CONTACT_ID = 'c-agent'; + +let db: BetterSQLite3Database; +let sqlite: { close: () => void }; +let rawDb: D1Database; +let sent: string[][]; + +beforeEach(async () => { + const fx = createTestDb(); + db = fx.db as BetterSQLite3Database; + sqlite = fx.sqlite; + await setupSchema(fx.sqlite); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockDrizzle as any).mockReturnValue(db); + rawDb = toRawD1(fx.sqlite); + sent = []; + + await db.insert(schema.tenants).values({ + id: TENANT, name: 'Acme', slug: TENANT, status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + } as never); +}); +afterEach(() => sqlite.close()); + +/** + * A partner agent as they actually exist: a GLOBAL `users` row (`tenant_id IS + * NULL` — one account across every company they refer to) plus a per-tenant + * `contacts` row bound to it by `autoLinkSameEmail`. The send happens inside one + * tenant, so the contact is the identity the port can resolve there. + */ +async function seedAgent() { + await db.insert(schema.users).values({ + id: AGENT_ID, tenantId: null, email: AGENT_EMAIL, name: 'Jane', + role: 'agent', passwordHash: 'H', createdAt: new Date(), + } as never); + await db.insert(schema.contacts).values({ + id: CONTACT_ID, tenantId: TENANT, type: 'agent', name: 'Jane', + email: AGENT_EMAIL, agentUserId: AGENT_ID, createdAt: new Date(), + } as never); +} + +async function choose(classId: string, enabled: boolean) { + await db.insert(schema.notificationPreferences).values({ + id: `np-${classId}`, tenantId: TENANT, subjectKind: 'contact', subjectId: CONTACT_ID, + classId, channel: 'email', enabled, createdAt: new Date(), updatedAt: new Date(), + } as never); +} -interface AgentRecipient { - id: string; - email: string; - name: string | null; - notifyOnReferral: boolean; - notifyOnReport: boolean; - notifyOnPaid: boolean; +/** + * No renderer is injected, so `renderOr` returns each method's fallback body + * with `enabled: true` and `sendRendered` still stamps the class from the + * trigger. That is the path under test — nothing here depends on a template row + * existing, which would otherwise let a send be skipped for the wrong reason. + */ +function service() { + return new EmailService( + 're_test', 'from@acme.com', 'Acme', + undefined, undefined, undefined, + { sendEmail: async (m: { to: string[] }) => { sent.push(m.to); return { ok: true as const, id: 'm1' }; } }, + undefined, undefined, + buildNotificationPreferences(rawDb, TENANT), + ); } -describe('EmailService — A2 agent notification preference gating', () => { - let svc: EmailService; - let sendEmailSpy: ReturnType; +const referral = { propertyAddress: '1 Main', clientName: 'Sarah', dashboardUrl: 'https://example.com/agent-dashboard' }; +const report = { propertyAddress: '1 Main', reportUrl: 'https://example.com/report/i-1?view=agent' }; +const paid = { propertyAddress: '1 Main', amountCents: 47500 }; +const agentArg = { id: AGENT_ID, email: AGENT_EMAIL, name: 'Jane' }; - beforeEach(() => { - svc = new EmailService('test-api-key', 'noreply@test.com', 'OpenInspection'); - sendEmailSpy = vi.fn().mockResolvedValue(undefined); - // Replace the underlying sendEmail with a spy so we can assert on - // delivery attempts without hitting Resend. - (svc as unknown as { sendEmail: typeof sendEmailSpy }).sendEmail = sendEmailSpy; +describe('agent notifications the agent chose to keep', () => { + it('sends a new referral when nothing says otherwise', async () => { + await seedAgent(); + await service().sendNewReferral(agentArg, referral); + expect(sent).toEqual([[AGENT_EMAIL]]); }); - function agent(overrides: Partial = {}): AgentRecipient { - return { - id: 'a1', - email: 'jane@realty.com', - name: 'Jane', - notifyOnReferral: true, - notifyOnReport: true, - notifyOnPaid: false, - ...overrides, - }; - } - - describe('sendNewReferral', () => { - it('sends when notifyOnReferral is true', async () => { - await svc.sendNewReferral(agent({ notifyOnReferral: true }), { - propertyAddress: '1 Main', clientName: 'Sarah', dashboardUrl: 'https://example.com/agent-dashboard', - }); - expect(sendEmailSpy).toHaveBeenCalledTimes(1); - const args = sendEmailSpy.mock.calls[0]!; - expect(args[0]).toEqual(['jane@realty.com']); - expect(args[1]).toMatch(/referral|1 Main/i); - }); - - it('skips send when notifyOnReferral is false', async () => { - await svc.sendNewReferral(agent({ notifyOnReferral: false }), { - propertyAddress: '1 Main', clientName: 'Sarah', dashboardUrl: 'https://example.com/agent-dashboard', - }); - expect(sendEmailSpy).not.toHaveBeenCalled(); - }); + it('sends a report-ready notice when nothing says otherwise', async () => { + await seedAgent(); + await service().sendAgentReportReady(agentArg, report); + expect(sent).toEqual([[AGENT_EMAIL]]); + }); +}); + +describe('agent notifications the agent switched off', () => { + it('withholds a new referral', async () => { + await seedAgent(); + await choose('agent-new-referral', false); + await service().sendNewReferral(agentArg, referral); + expect(sent).toEqual([]); + }); + + it('withholds a report-ready notice', async () => { + await seedAgent(); + await choose('agent-report-ready', false); + await service().sendAgentReportReady(agentArg, report); + expect(sent).toEqual([]); + }); + + it('does not confuse the two — muting referrals leaves report-ready alone', async () => { + // The class comes from the trigger that rendered the body, so this is + // the assertion that a method cannot be gated as its neighbour. + await seedAgent(); + await choose('agent-new-referral', false); + await service().sendAgentReportReady(agentArg, report); + expect(sent).toEqual([[AGENT_EMAIL]]); + }); +}); + +describe('invoice-paid is the one that defaults to OFF', () => { + /** + * `is_paid_notification_enabled` defaulted to FALSE, and the new model reads + * absence as "send". Migrating naively would have started mailing every + * partner agent about every payment. `defaultEnabled: false` on the class + * moved the default across with the data, so absence here means silence and + * the stored row is what says "yes, send me this". + */ + it('withholds invoice-paid with no row at all', async () => { + await seedAgent(); + await service().sendInvoicePaid(agentArg, paid); + expect(sent).toEqual([]); }); - describe('sendAgentReportReady', () => { - it('sends when notifyOnReport is true', async () => { - await svc.sendAgentReportReady(agent({ notifyOnReport: true }), { - propertyAddress: '1 Main', reportUrl: 'https://example.com/report/i-1?view=agent', - }); - expect(sendEmailSpy).toHaveBeenCalledTimes(1); - }); - - it('skips send when notifyOnReport is false', async () => { - await svc.sendAgentReportReady(agent({ notifyOnReport: false }), { - propertyAddress: '1 Main', reportUrl: 'https://example.com/report/i-1?view=agent', - }); - expect(sendEmailSpy).not.toHaveBeenCalled(); - }); + it('sends invoice-paid once the agent asks for it', async () => { + await seedAgent(); + await choose('agent-invoice-paid', true); + await service().sendInvoicePaid(agentArg, paid); + expect(sent).toEqual([[AGENT_EMAIL]]); }); - describe('sendInvoicePaid', () => { - it('sends when notifyOnPaid is true', async () => { - await svc.sendInvoicePaid(agent({ notifyOnPaid: true }), { - propertyAddress: '1 Main', amountCents: 47500, - }); - expect(sendEmailSpy).toHaveBeenCalledTimes(1); - }); - - it('skips send when notifyOnPaid is false (default)', async () => { - await svc.sendInvoicePaid(agent({ notifyOnPaid: false }), { - propertyAddress: '1 Main', amountCents: 47500, - }); - expect(sendEmailSpy).not.toHaveBeenCalled(); - }); + it('stays off for an address the tenant cannot resolve to anyone', async () => { + // Nothing seeded: no user, no contact. The class default is the only + // answer available, and for this one it is "do not send" — an + // unresolvable address must not become a way around an off-by-default. + await service().sendInvoicePaid(agentArg, paid); + expect(sent).toEqual([]); }); }); diff --git a/tests/unit/agents/agent-service-listings.spec.ts b/tests/unit/agents/agent-service-listings.spec.ts index 73981ba00..946c4a482 100644 --- a/tests/unit/agents/agent-service-listings.spec.ts +++ b/tests/unit/agents/agent-service-listings.spec.ts @@ -324,19 +324,16 @@ describe('AgentService.updateProfile — A2', () => { svc = new AgentService({} as D1Database); }); - it('persists slug + notification prefs', async () => { - await svc.updateProfile(AGENT_USER, { - slug: 'jane', - notifyOnReferral: true, - notifyOnReport: false, - notifyOnPaid: true, - }); + it('persists the profile fields it is given', async () => { + // Notification preferences used to be part of this patch, as three + // booleans on `users`. They now live in `notification_preferences` and + // are written by their own route — see + // `tests/unit/notifications/preferences-api.spec.ts`. + await svc.updateProfile(AGENT_USER, { slug: 'jane', name: 'Jane R.' }); const row = await testDb.select().from(schema.users) .where(eq(schema.users.id, AGENT_USER)).get(); expect(row?.slug).toBe('jane'); - expect(row?.notifyOnReferral).toBe(true); - expect(row?.notifyOnReport).toBe(false); - expect(row?.notifyOnPaid).toBe(true); + expect(row?.name).toBe('Jane R.'); }); it('rejects slug taken by another global agent user', async () => { @@ -347,11 +344,14 @@ describe('AgentService.updateProfile — A2', () => { }); it('does not write fields that were not provided', async () => { + // A patch names what changed. A later call that names something else + // must leave the first alone — otherwise editing a display name would + // silently drop the referral link the slug backs. await svc.updateProfile(AGENT_USER, { slug: 'jane' }); - await svc.updateProfile(AGENT_USER, { notifyOnPaid: true }); + await svc.updateProfile(AGENT_USER, { name: 'Jane R.' }); const row = await testDb.select().from(schema.users) .where(eq(schema.users.id, AGENT_USER)).get(); expect(row?.slug).toBe('jane'); - expect(row?.notifyOnPaid).toBe(true); + expect(row?.name).toBe('Jane R.'); }); }); diff --git a/tests/unit/notifications/agent-preferences-api.spec.ts b/tests/unit/notifications/agent-preferences-api.spec.ts new file mode 100644 index 000000000..faca62ce2 --- /dev/null +++ b/tests/unit/notifications/agent-preferences-api.spec.ts @@ -0,0 +1,250 @@ +/** + * A partner agent's preferences, which are PER COMPANY. + * + * That is the part worth pinning. An agent account is global and its JWT + * carries no tenant, so there is no session tenant to scope a row to; what the + * agent has is one `contacts` row per company that works with them. Everything + * below follows from that: the company comes from the agent's own bindings + * (never from the body), a revoked binding stops being a target, and `scope: + * 'all'` is what makes "I mean everyone" a single action instead of N. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { OpenAPIHono } from '@hono/zod-openapi'; +import { createTestDb, setupSchema } from '../db'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import * as schema from '../../../server/lib/db/schema'; +import type { HonoConfig } from '../../../server/types/hono'; +import { AppError } from '../../../server/lib/errors'; + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; + +// eslint-disable-next-line import/order +import agentNotificationPreferenceRoutes from '../../../server/api/agent/notification-preferences'; + +const AGENT = 'ag1'; +const ACME = 't-acme'; +const BOLT = 't-bolt'; + +let db: BetterSQLite3Database; +let sqlite: { close: () => void }; + +function buildApp(role = 'agent', sub = AGENT) { + const app = new OpenAPIHono(); + app.onError((err, c) => { + if (err instanceof AppError) { + return c.json({ success: false, error: { code: err.code, message: err.message } }, err.status); + } + return c.json({ success: false, error: { code: 'internal', message: String(err) } }, 500); + }); + app.use('*', async (c, next) => { + // No tenantId — an agent JWT deliberately carries none. + c.set('userRole', role); + c.set('user', { sub, role } as never); + await next(); + }); + app.route('/api/agent', agentNotificationPreferenceRoutes); + return app; +} + +const put = (app: OpenAPIHono, body: unknown) => + app.request('/api/agent/notification-preferences', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }, { DB: {} }); + +const get = (app: OpenAPIHono, qs = '') => + app.request(`/api/agent/notification-preferences${qs}`, {}, { DB: {} }); + +beforeEach(async () => { + const fx = createTestDb(); + db = fx.db as BetterSQLite3Database; + sqlite = fx.sqlite; + await setupSchema(fx.sqlite); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockDrizzle as any).mockReturnValue(db); + + for (const [id, name] of [[ACME, 'Acme Inspections'], [BOLT, 'Bolt Home Services']]) { + await db.insert(schema.tenants).values({ + id, name, slug: id, status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + } as never); + } + await db.insert(schema.users).values({ + id: AGENT, tenantId: null, email: 'jane@realty.com', name: 'Jane', + role: 'agent', passwordHash: 'H', createdAt: new Date(), + } as never); +}); +afterEach(() => sqlite.close()); + +/** The per-tenant `contacts` row `autoLinkSameEmail` binds to an agent account. */ +async function link(tenantId: string, contactId: string, revokedAt?: Date) { + await db.insert(schema.contacts).values({ + id: contactId, tenantId, type: 'agent', name: 'Jane', email: 'jane@realty.com', + agentUserId: AGENT, agentRevokedAt: revokedAt ?? null, createdAt: new Date(), + } as never); +} + +const rows = () => db.select().from(schema.notificationPreferences).all(); + +describe('GET /api/agent/notification-preferences', () => { + it('lists the companies this agent works with, by name', async () => { + await link(ACME, 'c-acme'); + await link(BOLT, 'c-bolt'); + + const body = await (await get(buildApp())).json() as { + data: { companies: Array<{ id: string; name: string }>; selected: string }; + }; + expect(body.data.companies).toEqual([ + { id: ACME, name: 'Acme Inspections' }, + { id: BOLT, name: 'Bolt Home Services' }, + ]); + expect(body.data.selected).toBe(ACME); + }); + + it('leaves out a company that revoked this agent', async () => { + // Revocation is stamped, not cleared, so the binding row still exists. + // Listing it would offer a control over sends the agent no longer gets. + await link(ACME, 'c-acme'); + await link(BOLT, 'c-bolt', new Date()); + + const body = await (await get(buildApp())).json() as { data: { companies: Array<{ id: string }> } }; + expect(body.data.companies.map((x) => x.id)).toEqual([ACME]); + }); + + it('reads one company at a time, and they do not bleed into each other', async () => { + await link(ACME, 'c-acme'); + await link(BOLT, 'c-bolt'); + const app = buildApp(); + await put(app, { classId: 'agent-new-referral', channel: 'email', enabled: false, companyId: ACME }); + + const acme = await (await get(app, `?companyId=${ACME}`)).json() as { + data: { youChoose: Array<{ id: string; channels: Record }> }; + }; + const bolt = await (await get(app, `?companyId=${BOLT}`)).json() as { + data: { youChoose: Array<{ id: string; channels: Record }> }; + }; + expect(acme.data.youChoose.find((r) => r.id === 'agent-new-referral')!.channels.email).toBe('off'); + expect(bolt.data.youChoose.find((r) => r.id === 'agent-new-referral')!.channels.email).toBe('on'); + }); + + it('shows the AGENT list — not staff’s, not the client’s', async () => { + await link(ACME, 'c-acme'); + const body = await (await get(buildApp())).json() as { + data: { youChoose: Array<{ id: string }>; alwaysSent: Array<{ id: string }> }; + }; + expect(body.data.youChoose.map((r) => r.id)).toContain('agent-new-referral'); + expect(body.data.youChoose.map((r) => r.id)).not.toContain('review-request'); + expect(body.data.alwaysSent.map((r) => r.id)).not.toContain('workspace-invitation'); + }); + + it('still answers for an agent no company is bound to', async () => { + // A new signup, or someone every company revoked. An empty list is a + // screen with something to say; a 400 on a read is a dead end. + const body = await (await get(buildApp())).json() as { + data: { companies: unknown[]; selected: string | null; youChoose: unknown[] }; + }; + expect(body.data.companies).toEqual([]); + expect(body.data.selected).toBeNull(); + expect(body.data.youChoose.length).toBeGreaterThan(0); + }); +}); + +describe('PUT /api/agent/notification-preferences', () => { + it('writes against the agent’s own contact at the named company', async () => { + await link(ACME, 'c-acme'); + const res = await put(buildApp(), { + classId: 'agent-new-referral', channel: 'email', enabled: false, companyId: ACME, + }); + expect(res.status).toBe(200); + + const saved = await rows(); + expect(saved).toHaveLength(1); + expect(saved[0].tenantId).toBe(ACME); + expect(saved[0].subjectKind).toBe('contact'); + expect(saved[0].subjectId).toBe('c-acme'); + }); + + it('applies to every linked company when the agent says all', async () => { + await link(ACME, 'c-acme'); + await link(BOLT, 'c-bolt'); + const res = await put(buildApp(), { + classId: 'agent-new-referral', channel: 'email', enabled: false, scope: 'all', + }); + expect(await res.json()).toMatchObject({ applied: 2 }); + expect((await rows()).map((r) => r.tenantId).sort()).toEqual([ACME, BOLT]); + }); + + it('refuses a company this agent is not bound to', async () => { + // The body names a company, never a subject — the contact id is looked + // up from the agent's own bindings, so this is the whole attack surface + // and it ends in a 400. + await link(ACME, 'c-acme'); + const res = await put(buildApp(), { + classId: 'agent-new-referral', channel: 'email', enabled: false, companyId: BOLT, + }); + expect(res.status).toBe(400); + expect(await rows()).toHaveLength(0); + }); + + it('refuses a company that revoked this agent', async () => { + await link(BOLT, 'c-bolt', new Date()); + const res = await put(buildApp(), { + classId: 'agent-new-referral', channel: 'email', enabled: false, companyId: BOLT, + }); + expect(res.status).toBe(400); + expect(await rows()).toHaveLength(0); + }); + + it('refuses a notification that is always sent', async () => { + await link(ACME, 'c-acme'); + const res = await put(buildApp(), { + classId: 'agent-login-link', channel: 'email', enabled: false, companyId: ACME, + }); + expect(res.status).toBe(400); + expect(await rows()).toHaveLength(0); + }); + + it('refuses a class this agent is never addressed by', async () => { + await link(ACME, 'c-acme'); + const res = await put(buildApp(), { + classId: 'concierge-inspector-review', channel: 'email', enabled: false, companyId: ACME, + }); + expect(res.status).toBe(400); + expect(await rows()).toHaveLength(0); + }); + + it('DELETES the row when switched back on, rather than storing the default', async () => { + await link(ACME, 'c-acme'); + const app = buildApp(); + await put(app, { classId: 'agent-new-referral', channel: 'email', enabled: false, companyId: ACME }); + expect(await rows()).toHaveLength(1); + await put(app, { classId: 'agent-new-referral', channel: 'email', enabled: true, companyId: ACME }); + expect(await rows()).toHaveLength(0); + }); + + it('STORES the row when invoice-paid is switched ON, because that one defaults to off', async () => { + // The mirror of the test above, and the reason the rule is phrased as + // "store only what differs from the default" rather than "delete on + // enable": here the row is what says yes. + await link(ACME, 'c-acme'); + const app = buildApp(); + await put(app, { classId: 'agent-invoice-paid', channel: 'email', enabled: true, companyId: ACME }); + const saved = await rows(); + expect(saved).toHaveLength(1); + expect(saved[0].enabled).toBe(true); + + await put(app, { classId: 'agent-invoice-paid', channel: 'email', enabled: false, companyId: ACME }); + expect(await rows()).toHaveLength(0); + }); + + it('turns away a caller who is not an agent', async () => { + await link(ACME, 'c-acme'); + const res = await put(buildApp('owner', 'u-staff'), { + classId: 'agent-new-referral', channel: 'email', enabled: false, companyId: ACME, + }); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(await rows()).toHaveLength(0); + }); +}); diff --git a/tests/unit/notifications/preferences-api.spec.ts b/tests/unit/notifications/preferences-api.spec.ts index 5fce0de63..f13ac3af5 100644 --- a/tests/unit/notifications/preferences-api.spec.ts +++ b/tests/unit/notifications/preferences-api.spec.ts @@ -24,6 +24,14 @@ import notificationPreferenceRoutes from '../../../server/api/notification-prefe const TENANT = 't-prefs-api'; const ME = 'u-me'; const SOMEONE_ELSE = 'u-other'; +/** + * The ONLY staff notification that is not required. That is a fact about the + * vocabulary, not about this test: everything else staff receive is either + * account access, a money record, or office dispatch that an individual is not + * allowed to silence for themselves (§2.5). If a second one ever appears, this + * constant is where a reader will look to find out. + */ +const MUTABLE = 'concierge-inspector-review'; let db: BetterSQLite3Database; let sqlite: { close: () => void }; @@ -69,8 +77,8 @@ describe('PUT /api/notification-preferences', () => { it('writes the mute against the SIGNED-IN reader, whatever the body says', async () => { // The body carries what changed, never who. Accepting a subject id here // would let anyone silence anyone. - const res = await put(buildApp('agent'), { - classId: 'agent-new-referral', channel: 'email', enabled: false, + const res = await put(buildApp(), { + classId: MUTABLE, channel: 'email', enabled: false, subjectId: SOMEONE_ELSE, userId: SOMEONE_ELSE, }); expect(res.status).toBe(200); @@ -112,18 +120,18 @@ describe('PUT /api/notification-preferences', () => { it('DELETES the row when switched back on, rather than storing the default', async () => { // §3.2 — never store a row that merely restates the default; it makes // the table grow with the user base instead of with the decisions. - const app = buildApp('agent'); - await put(app, { classId: 'agent-new-referral', channel: 'email', enabled: false }); + const app = buildApp(); + await put(app, { classId: MUTABLE, channel: 'email', enabled: false }); expect(await rows()).toHaveLength(1); - await put(app, { classId: 'agent-new-referral', channel: 'email', enabled: true }); + await put(app, { classId: MUTABLE, channel: 'email', enabled: true }); expect(await rows()).toHaveLength(0); }); it('is idempotent — muting twice leaves one row, not two', async () => { - const app = buildApp('agent'); - await put(app, { classId: 'agent-new-referral', channel: 'email', enabled: false }); - await put(app, { classId: 'agent-new-referral', channel: 'email', enabled: false }); + const app = buildApp(); + await put(app, { classId: MUTABLE, channel: 'email', enabled: false }); + await put(app, { classId: MUTABLE, channel: 'email', enabled: false }); expect(await rows()).toHaveLength(1); }); }); @@ -132,38 +140,39 @@ describe('GET /api/notification-preferences', () => { const get = (app: OpenAPIHono) => app.request('/api/notification-preferences', {}, { DB: {} }); - it('reports a mute this reader holds as off, and leaves the rest on', async () => { - const app = buildApp('agent'); - await put(app, { classId: 'agent-new-referral', channel: 'email', enabled: false }); + it('reports a mute this reader holds as off', async () => { + const app = buildApp(); + await put(app, { classId: MUTABLE, channel: 'email', enabled: false }); const body = await (await get(app)).json() as { data: { youChoose: Array<{ id: string; channels: Record }> }; }; - const row = body.data.youChoose.find((r) => r.id === 'agent-new-referral')!; - expect(row.channels.email).toBe('off'); - const other = body.data.youChoose.find((r) => r.id === 'agent-report-ready')!; - expect(other.channels.email).toBe('on'); + expect(body.data.youChoose.find((r) => r.id === MUTABLE)!.channels.email).toBe('off'); }); it('does not show one reader another reader’s choices', async () => { await db.insert(schema.notificationPreferences).values({ id: 'np-theirs', tenantId: TENANT, subjectKind: 'user', subjectId: SOMEONE_ELSE, - classId: 'agent-new-referral', channel: 'email', enabled: false, + classId: MUTABLE, channel: 'email', enabled: false, createdAt: new Date(), updatedAt: new Date(), } as never); - const body = await (await get(buildApp('agent'))).json() as { + const body = await (await get(buildApp())).json() as { data: { youChoose: Array<{ id: string; channels: Record }> }; }; - expect(body.data.youChoose.find((r) => r.id === 'agent-new-referral')!.channels.email).toBe('on'); + expect(body.data.youChoose.find((r) => r.id === MUTABLE)!.channels.email).toBe('on'); }); - it('shows an agent the agent list and staff the staff list', async () => { - const staff = await (await get(buildApp('owner'))).json() as { data: { alwaysSent: Array<{ id: string }> } }; - const agent = await (await get(buildApp('agent'))).json() as { data: { youChoose: Array<{ id: string }> } }; - - expect(staff.data.alwaysSent.map((r) => r.id)).toContain('workspace-invitation'); - expect(agent.data.youChoose.map((r) => r.id)).toContain('agent-new-referral'); - expect(agent.data.youChoose.map((r) => r.id)).not.toContain('review-request'); + it('shows the STAFF list, and never another audience’s', async () => { + // This route is the staff surface, full stop. An agent's JWT carries no + // tenant at all, so the old "read the role and pick an audience" line + // could only ever have been answering for a caller that cannot reach + // here — and would have written rows under an undefined tenant. + const body = await (await get(buildApp())).json() as { + data: { alwaysSent: Array<{ id: string }>; youChoose: Array<{ id: string }> }; + }; + expect(body.data.alwaysSent.map((r) => r.id)).toContain('workspace-invitation'); + expect(body.data.youChoose.map((r) => r.id)).toEqual([MUTABLE]); + expect(body.data.youChoose.map((r) => r.id)).not.toContain('agent-new-referral'); }); }); diff --git a/tests/unit/notifications/screen-model.spec.ts b/tests/unit/notifications/screen-model.spec.ts index 2cef8a159..fb7046ca6 100644 --- a/tests/unit/notifications/screen-model.spec.ts +++ b/tests/unit/notifications/screen-model.spec.ts @@ -10,7 +10,7 @@ import { describe, it, expect } from 'vitest'; import { buildScreenModel, classesFor } from '../../../server/lib/notifications/screen-model'; -const noMutes = new Set(); +const noChoices = new Map(); describe('notifications screen model', () => { it('never shows a reader something they cannot receive', () => { @@ -38,7 +38,7 @@ describe('notifications screen model', () => { }); it('splits into what we always send and what you choose, with nothing in both', () => { - const m = buildScreenModel('client', noMutes); + const m = buildScreenModel('client', noChoices); const always = new Set(m.alwaysSent.map((r) => r.id)); const choose = new Set(m.youChoose.map((r) => r.id)); expect([...always].filter((id) => choose.has(id))).toEqual([]); @@ -51,17 +51,29 @@ describe('notifications screen model', () => { // §4: `—` is distinct from "off". A review request has no in-app form; // an off-switch for it would be a lie about what exists, and a reader // who turned it on would be right to expect something. - const row = buildScreenModel('client', noMutes).youChoose.find((r) => r.id === 'review-request')!; + const row = buildScreenModel('client', noChoices).youChoose.find((r) => r.id === 'review-request')!; expect(row.channels.email).toBe('on'); expect(row.channels.in_app).toBe('unavailable'); expect(row.channels.sms).toBe('unavailable'); }); - it('reads absence as ON, and only an explicit row as off', () => { - const on = buildScreenModel('client', noMutes).youChoose.find((r) => r.id === 'booking-confirmation')!; + it('reads absence as the CLASS default, which is usually but not always on', () => { + // agent-invoice-paid was a column that defaulted to FALSE. The default + // moved with the data rather than being quietly dropped, so absence + // there means off — and turning it on is what gets stored. + const paid = buildScreenModel('agent', noChoices).youChoose.find((r) => r.id === 'agent-invoice-paid')!; + expect(paid.channels.email).toBe('off'); + + const on = buildScreenModel('agent', new Map([['agent-invoice-paid:email', true]])) + .youChoose.find((r) => r.id === 'agent-invoice-paid')!; + expect(on.channels.email).toBe('on'); + }); + + it('reads absence as ON for everything else, and only an explicit row as off', () => { + const on = buildScreenModel('client', noChoices).youChoose.find((r) => r.id === 'booking-confirmation')!; expect(on.channels.email).toBe('on'); - const off = buildScreenModel('client', new Set(['booking-confirmation:email'])) + const off = buildScreenModel('client', new Map([['booking-confirmation:email', false]])) .youChoose.find((r) => r.id === 'booking-confirmation')!; expect(off.channels.email).toBe('off'); // A mute is per CHANNEL — muting email must not silence the text. @@ -71,7 +83,7 @@ describe('notifications screen model', () => { it('cannot be talked into switching off something required', () => { // Even with a mute row present. `alwaysSent` carries no state at all, // so there is nothing for a stale row to flip. - const m = buildScreenModel('client', new Set(['report-ready:email'])); + const m = buildScreenModel('client', new Map([['report-ready:email', false]])); expect(m.alwaysSent.map((r) => r.id)).toContain('report-ready'); expect(m.youChoose.map((r) => r.id)).not.toContain('report-ready'); }); From cb896c12fbeecf4f2c081cf8e114169eb009b99f Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 31 Jul 2026 18:21:33 +0800 Subject: [PATCH 17/48] feat(notifications): bulk row/column actions, and honour preferences on SMS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the per-recipient preference screens with the grid-shaped bulk controls (`applyBulk`) and closes a hole the screen itself created. THE SMS SWITCH WAS A LIE. Two client classes declare an SMS channel (`booking-confirmation`, `inspection-reminder`), so the screen rendered a Text switch for them — and `smsSendGate` never consulted preferences, so ticking it off stored a row nothing read and the text went out anyway. That is exactly the defect this program exists to remove. The gate now takes a `classId` and checks the recipient's preference AFTER consent and BEFORE quota: after consent because a preference NARROWS what consent allows and must never widen it (§3.3), before quota because a text nobody wanted must not spend the tenant's allowance. Unclassified sends (an admin test send) stay unmutable, which is `isSuppressible` failing closed. Bulk actions are scoped the way the grid is: a row (every channel of one notification), a column (one channel across every notification), or the corner. Loose buttons above the table would have made the reader work out which cells each one touched. `reset` is a separate verb from `enable` and the difference is load-bearing: reset DELETES rows so each class returns to its own default, and `agent-invoice-paid` defaults to OFF. Two things Chrome caught that no unit test could: - an all-unavailable column rendered a bulk checkbox that looked like "all off" and did nothing when clicked — the em dash's own lie, reintroduced one level up. A scope with no selectable cell now renders no control. - `?section=notifications` fell through to the overview: `HubSection` had the member, the hand-maintained `HUB_SECTIONS` array did not. Replaced with a `Record` so the compiler keeps them in sync (CLAUDE.md: make a "must stay in sync" coupling executable, not a comment). Feedback moved to the existing ToastPortal. The inline red line it replaces sat inside a card the reader may well have scrolled past — a message about mail they will not receive, placed where they cannot see it. Only the in-flight state stays inline, next to the switch that was touched. The em dash now carries a one-line legend: a symbol a reader has to ask about has been left to guess, and the natural guess here ("it's off") is the wrong one. Chrome walkthrough, both themes: staff (17 always-sent / 1 choosable, write and delete round trip), agent (per-company isolation verified across two companies, column action wrote exactly one row because every other class already defaults to on). The client Hub section is reachable after the section fix; its grid is the same shared component the other two exercise. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RgBRZQhGELdkaWGorWwWKV --- .../NotificationPreferences.test.tsx | 181 ++++++++---------- .../notifications/NotificationPreferences.tsx | 126 ++++++++++-- .../portal/hub/PortalNotificationSection.tsx | 28 ++- .../settings/NotificationPreferencesCard.tsx | 23 ++- app/hooks/useNotificationSaveToast.ts | 42 ++++ app/lib/portal-notification-preferences.ts | 24 +++ app/lib/section-loaders.ts | 33 +++- app/lib/settings-notifications.server.ts | 25 +++ app/routes/agent/settings-profile.tsx | 45 ++++- app/routes/public/portal-inspection.tsx | 8 + app/routes/settings-profile.tsx | 6 +- messages/en/components.json | 6 + scripts/file-size-baseline.json | 7 +- server/api/agent/notification-preferences.ts | 56 +++++- server/api/notification-preferences.ts | 38 +++- server/api/portal/notification-preferences.ts | 46 ++++- server/lib/mcp/openapi-snapshot.json | 156 +++++++++++++++ server/lib/notifications/preference-write.ts | 74 +++++++ server/lib/sms/send-gate.ts | 29 ++- server/services/automation/send-one-sms.ts | 13 ++ server/services/automation/sms.ts | 3 + tests/unit/messaging/sms-send-gate.spec.ts | 53 +++++ .../notifications/preference-bulk.spec.ts | 122 ++++++++++++ 23 files changed, 994 insertions(+), 150 deletions(-) create mode 100644 app/hooks/useNotificationSaveToast.ts create mode 100644 tests/unit/notifications/preference-bulk.spec.ts diff --git a/app/components/notifications/NotificationPreferences.test.tsx b/app/components/notifications/NotificationPreferences.test.tsx index 45e5f4316..5fc2162ad 100644 --- a/app/components/notifications/NotificationPreferences.test.tsx +++ b/app/components/notifications/NotificationPreferences.test.tsx @@ -1,118 +1,97 @@ /** - * The three choices §4 makes are the three things worth asserting, because each - * one is a place where the obvious implementation would quietly lie to the - * reader. + * The bulk controls, and the one rule that is easy to get backwards. * - * These test what a reader SEES and what a click DOES — not which components - * were used. A rewrite that keeps the promises should pass. - * - * WHAT THESE CANNOT TELL YOU. `user-event` refuses to click an element with - * `pointer-events: none`, which reads like a reachability check — and here it - * mostly is not one. Vitest loads no Tailwind CSS, so a `pointer-events-none` - * CLASS has nothing behind it and the click goes through; only an inline style - * is caught (both verified). Real unreachability in this codebase comes from - * classes and overlays, so it is invisible at this level. Whether the control - * can actually be reached, and whether it is legible in both themes, is a - * question only the Chrome walkthrough answers. + * A row/column/corner checkbox is a promise about which cells it touches. The + * failure worth pinning is the empty column: every cell an em dash, and a + * checkbox above it that looks like "all off" and does nothing when clicked — + * the exact lie the em dash exists to prevent, reintroduced one level up. It + * shipped in the first draft and was caught in Chrome, not here. */ import { describe, it, expect, vi } from "vitest"; -import { render, screen, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; - -import { NotificationPreferences } from "./NotificationPreferences"; +import { render, fireEvent } from "@testing-library/react"; +import { NotificationPreferences, type ChoiceRow } from "./NotificationPreferences"; -const ALWAYS = [ - { id: "password-reset", label: "Password reset", channels: ["email"] }, - { id: "report-ready", label: "Your report is ready", channels: ["email"] }, +const rows: ChoiceRow[] = [ + { id: "a", label: "Alpha", channels: { email: "on", sms: "unavailable", in_app: "unavailable" } }, + { id: "b", label: "Beta", channels: { email: "off", sms: "unavailable", in_app: "on" } }, ]; -const CHOOSE = [ - { - id: "booking-confirmation", - label: "Booking confirmation", - channels: { email: "on", sms: "off", in_app: "unavailable" }, - }, - { - id: "review-request", - label: "How did we do?", - channels: { email: "off", sms: "unavailable", in_app: "unavailable" }, - }, -] as const; - -const user = userEvent.setup(); +function setup(over: Partial[0]> = {}) { + const onBulk = vi.fn(); + const onChange = vi.fn(); + const utils = render( + , + ); + return { ...utils, onBulk, onChange }; +} -const renderScreen = (onChange = vi.fn()) => { - render( - ({ ...r, channels: { ...r.channels } }))} - onChange={onChange} - />, - ); - return onChange; -}; +const boxes = (c: ReturnType) => + [...c.container.querySelectorAll("input[type=checkbox]")] as HTMLInputElement[]; +const byLabel = (c: ReturnType, startsWith: string) => + boxes(c).find((b) => (b.getAttribute("aria-label") ?? "").startsWith(startsWith))!; -describe("NotificationPreferences", () => { - it("offers no switch at all for what is always sent", async () => { - // A greyed-out toggle invites the reader to try, then refuses. The - // always-sent group answers the question instead of posing it, so there - // must be nothing there to click. - renderScreen(); - await user.click(screen.getByText(/show what these are/i)); +describe("bulk controls", () => { + it("renders NO control for a channel every row is unavailable on", () => { + // Text is an em dash on both rows. A checkbox there would be a control + // over nothing. + const c = setup(); + expect(boxes(c).some((b) => (b.getAttribute("aria-label") ?? "").includes("Text"))).toBe(false); + // In-app has one real cell, so it keeps its control. + expect(byLabel(c, "Turn In-app")).toBeTruthy(); + }); - const alwaysItem = screen.getByText("Password reset").closest("li")!; - expect(within(alwaysItem).queryByRole("checkbox")).toBeNull(); - }); + it("shows a partly-on column as indeterminate, not as unchecked", () => { + // Alpha's email is on and Beta's is off. Rendering that as plain unchecked + // would invite "select all" on a column that is already half selected. + const c = setup(); + const email = byLabel(c, "Turn Email"); + expect(email.checked).toBe(false); + expect(email.indeterminate).toBe(true); + }); - it("tells the reader how many they cannot switch off", () => { - // §4: a number a reader can hold beats a sentence they have to trust. - renderScreen(); - const always = screen.getByRole("region", { name: /always sent/i }); - expect(within(always).getByText(String(ALWAYS.length))).toBeInTheDocument(); - expect(within(always).getByText(/cannot be switched off/i)).toBeInTheDocument(); - }); + it("turns a partly-on column fully ON when clicked", () => { + const c = setup(); + fireEvent.click(byLabel(c, "Turn Email")); + expect(c.onBulk).toHaveBeenCalledWith(true, { channel: "email" }); + }); - it("shows a dash, not an empty switch, for a channel the notification never uses", () => { - // The distinction that matters: "off" is a choice the reader made, - // "—" is a form that does not exist. An unchecked box would invite them - // to turn on something that can never happen. - renderScreen(); - const row = screen.getAllByRole("row").find((r) => within(r).queryByText("How did we do?"))!; - // email is a real control; the other two are not controls at all. - expect(within(row).getAllByRole("checkbox")).toHaveLength(1); - expect(within(row).getAllByText("—")).toHaveLength(2); - }); + it("turns a fully-on row OFF when clicked", () => { + const c = setup(); + const row = byLabel(c, "Turn Alpha"); + expect(row.checked).toBe(true); + fireEvent.click(row); + expect(c.onBulk).toHaveBeenCalledWith(false, { classId: "a" }); + }); - it("reports which notification and which channel a click was about", async () => { - const onChange = renderScreen(); - await user.click(screen.getByRole("checkbox", { name: /booking confirmation — text/i })); - expect(onChange).toHaveBeenCalledWith("booking-confirmation", "sms", true); - }); + it("scopes the corner control to the whole grid", () => { + const c = setup(); + fireEvent.click(byLabel(c, "Turn every")); + expect(c.onBulk).toHaveBeenCalledWith(true, {}); + }); - it("turns something off as readily as on — the control is not one-way", async () => { - const onChange = renderScreen(); - await user.click(screen.getByRole("checkbox", { name: /booking confirmation — email/i })); - expect(onChange).toHaveBeenCalledWith("booking-confirmation", "email", false); - }); + it("renders no bulk controls at all for a single-row screen", () => { + // The row, the column and the grid all resolve to the same cell there. + const c = setup({ youChoose: [rows[0]] }); + expect(boxes(c).filter((b) => (b.getAttribute("aria-label") ?? "").startsWith("Turn"))).toHaveLength(0); + }); - it("names every switch by its notification, so a screen reader is not left with three 'email's", () => { - renderScreen(); - for (const box of screen.getAllByRole("checkbox")) { - expect(box.getAttribute("aria-label")).toMatch(/ — /); - } - }); + it("shows the in-flight state inline, and never the result", () => { + // The RESULT is a toast, because it has to reach a reader who scrolled + // past this card. Only "saving" belongs next to the switch they touched. + const c = setup({ status: "saving" }); + expect(c.getByText(/Saving/)).toBeTruthy(); + expect(c.queryByText(/^Saved$/)).toBeNull(); + }); - it("stops accepting clicks while a save is in flight", () => { - render( - ({ ...r, channels: { ...r.channels } }))} - onChange={vi.fn()} - busy - />, - ); - for (const box of screen.getAllByRole("checkbox")) { - expect(box).toBeDisabled(); - } - }); + it("says what the dash means, rather than leaving it to be guessed", () => { + // A reader who has to ask will guess "off", which is the wrong answer. + const c = setup(); + expect(c.getByText(/dash means/i)).toBeTruthy(); + }); }); diff --git a/app/components/notifications/NotificationPreferences.tsx b/app/components/notifications/NotificationPreferences.tsx index aeb53707d..47319f834 100644 --- a/app/components/notifications/NotificationPreferences.tsx +++ b/app/components/notifications/NotificationPreferences.tsx @@ -59,6 +59,70 @@ export interface NotificationPreferencesProps { * a box that merely looks ticked. This is the reply. */ status?: "idle" | "saving" | "saved"; + /** + * Bulk change for one row, one column, or the whole grid. + * + * The controls sit ON the row and column rather than as loose buttons above + * the table, because the reader would otherwise have to work out which + * cells each button touched. A header checkbox says it by where it is. + * + * Omit to render no bulk controls at all — which is what a screen with a + * single choosable row should do, since there the one cell IS the control. + */ + onBulk?: (enabled: boolean, scope: { channel?: ChannelId; classId?: string }) => void; +} + +/** All | none | some of the cells in scope are on. `unavailable` never counts. */ +type BulkState = "all" | "none" | "some"; + +/** + * @returns `null` when the scope contains NO selectable cell — a column whose + * every row is an em dash, say. That must render no control at all: + * an empty checkbox there reads as "all off" and does nothing when + * clicked, which is precisely the lie the em dash exists to avoid. + */ +function bulkStateOf( + rows: ChoiceRow[], + scope: { channel?: ChannelId; classId?: string }, +): BulkState | null { + const cells: ChannelState[] = []; + for (const r of rows) { + if (scope.classId && r.id !== scope.classId) continue; + for (const c of CHANNELS) { + if (scope.channel && c.id !== scope.channel) continue; + const st = r.channels[c.id]; + // An em dash is not a control, so it is not a vote either — a column + // whose only rows are unavailable must not read as "all off". + if (st !== "unavailable") cells.push(st); + } + } + if (cells.length === 0) return null; + if (cells.every((c) => c === "on")) return "all"; + if (cells.every((c) => c === "off")) return "none"; + return "some"; +} + +function BulkBox({ + state, label, disabled, onToggle, +}: { + state: BulkState; + label: string; + disabled: boolean; + onToggle: (enabled: boolean) => void; +}) { + return ( + { if (el) el.indeterminate = state === "some"; }} + onChange={() => onToggle(state !== "all")} + /> + ); } const CHANNELS: ReadonlyArray<{ id: ChannelId; label: () => string }> = [ @@ -104,8 +168,12 @@ function ChannelCell({ } export function NotificationPreferences({ - alwaysSent, youChoose, onChange, busy = false, status = "idle", + alwaysSent, youChoose, onChange, busy = false, status = "idle", onBulk, }: NotificationPreferencesProps) { + // With one row there is nothing to batch: the row, the column and the grid + // all resolve to the same single cell, and three extra controls saying so + // is noise. + const bulk = onBulk && youChoose.length > 1 ? onBulk : undefined; return (
@@ -152,13 +220,11 @@ export function NotificationPreferences({

{m.notif_prefs_choose_heading()}

- {/* aria-live so the confirmation reaches a reader who cannot - see the row they just changed. Same words either way — - the switch already said WHAT changed. */} + {/* Only the IN-FLIGHT state lives here. The result is a + toast: it has to reach a reader who has scrolled past + this card, which an inline line cannot. */} - {status === "saving" ? m.notif_prefs_saving() - : status === "saved" ? m.notif_prefs_saved() - : ""} + {status === "saving" ? m.notif_prefs_saving() : ""}
@@ -171,10 +237,34 @@ export function NotificationPreferences({ // row is only a visual convenience for everyone else.
- + + {bulk && bulkStateOf(youChoose, {}) && ( + <> + bulk(enabled, {})} + /> + + {m.notif_prefs_bulk_all_short()} + + + )} + {CHANNELS.map((c) => ( - - {c.label()} + + + {c.label()} + + {bulk && bulkStateOf(youChoose, { channel: c.id }) && ( + bulk(enabled, { channel: c.id })} + /> + )} ))}
@@ -185,7 +275,17 @@ export function NotificationPreferences({ role="row" className="py-3 grid grid-cols-1 gap-2 sm:grid-cols-[1fr_repeat(3,5rem)] sm:items-center" > - {row.label} + + {bulk && bulkStateOf(youChoose, { classId: row.id }) && ( + bulk(enabled, { classId: row.id })} + /> + )} + {row.label} + {CHANNELS.map((c) => ( ))}
+ {/* The em dash needs saying once. A reader who has to + ask what a symbol means has been left to guess, and + the guess here ("it's off") is the wrong one. */} +

{m.notif_prefs_legend()}

)} diff --git a/app/components/portal/hub/PortalNotificationSection.tsx b/app/components/portal/hub/PortalNotificationSection.tsx index a4003d5f8..5069b5fa9 100644 --- a/app/components/portal/hub/PortalNotificationSection.tsx +++ b/app/components/portal/hub/PortalNotificationSection.tsx @@ -5,6 +5,7 @@ import { type ChannelId, type ChoiceRow, } from "~/components/notifications/NotificationPreferences"; +import { useNotificationSaveToast } from "~/hooks/useNotificationSaveToast"; import { m } from "~/paraglide/messages"; /** @@ -26,14 +27,26 @@ export function PortalNotificationSection({ error: string | null; }) { const fetcher = useFetcher<{ ok?: boolean; intent?: string; error?: string }>(); - const result = fetcher.data?.intent === "notification-preference" ? fetcher.data : null; + const result = fetcher.data?.intent === "notification-preference" || fetcher.data?.intent === "notification-bulk" + ? fetcher.data : null; const saveError = result && result.ok === false ? result.error : null; + useNotificationSaveToast({ data: result, failed: !!saveError, error: saveError }); // "saved" persists after the fetcher goes idle, so the confirmation is still // on screen when the reader looks up from the switch they just moved. - const status = fetcher.state !== "idle" ? "saving" as const - : saveError ? "idle" as const - : fetcher.data ? "saved" as const : "idle" as const; + const status = fetcher.state !== "idle" ? "saving" as const : "idle" as const; + + function bulk(enabled: boolean, scope: { channel?: ChannelId; classId?: string }) { + fetcher.submit( + { + intent: "notification-bulk", + action: enabled ? "enable" : "disable", + ...(scope.channel ? { channel: scope.channel } : {}), + ...(scope.classId ? { classId: scope.classId } : {}), + }, + { method: "post" }, + ); + } function save(classId: string, channel: ChannelId, enabled: boolean) { fetcher.submit( @@ -48,15 +61,16 @@ export function PortalNotificationSection({

{m.portal_notif_heading()}

{m.portal_notif_desc()}

- {(error || saveError) && ( -

{error ?? saveError}

- )} + {/* Only the LOAD failure stays inline — it explains why the grid below + is missing, so it belongs where the grid would have been. */} + {error &&

{error}

}
); diff --git a/app/components/settings/NotificationPreferencesCard.tsx b/app/components/settings/NotificationPreferencesCard.tsx index 361a9daf9..f66b70822 100644 --- a/app/components/settings/NotificationPreferencesCard.tsx +++ b/app/components/settings/NotificationPreferencesCard.tsx @@ -5,6 +5,7 @@ import { type ChannelId, type ChoiceRow, } from "~/components/notifications/NotificationPreferences"; +import { useNotificationSaveToast } from "~/hooks/useNotificationSaveToast"; import { m } from "~/paraglide/messages"; /** @@ -32,14 +33,26 @@ export function NotificationPreferencesCard({ loadError: string | null; }) { const fetcher = useFetcher<{ success?: boolean; error?: string; intent?: string }>(); - const result = fetcher.data?.intent === "save-notification" ? fetcher.data : null; + const result = fetcher.data?.intent === "save-notification" || fetcher.data?.intent === "bulk-notification" + ? fetcher.data : null; const error = result && result.success === false ? result.error : null; + useNotificationSaveToast({ data: result, failed: !!error, error }); // "saved" persists after the fetcher goes idle, so the confirmation is still // on screen when the reader looks up from the switch they just moved. - const status = fetcher.state !== "idle" ? "saving" as const - : error ? "idle" as const - : fetcher.data ? "saved" as const : "idle" as const; + const status = fetcher.state !== "idle" ? "saving" as const : "idle" as const; + + function bulk(enabled: boolean, scope: { channel?: ChannelId; classId?: string }) { + fetcher.submit( + { + intent: "bulk-notification", + action: enabled ? "enable" : "disable", + ...(scope.channel ? { channel: scope.channel } : {}), + ...(scope.classId ? { classId: scope.classId } : {}), + }, + { method: "post" }, + ); + } function save(classId: string, channel: ChannelId, enabled: boolean) { fetcher.submit( @@ -55,7 +68,6 @@ export function NotificationPreferencesCard({

{m.settings_notifications_heading()}

{m.settings_notifications_desc()}

- {error &&

{error}

} {loadError ? ( // Never render the two counts when the read failed. "0 notifications // you cannot switch off" is a confident false answer, and the count is @@ -68,6 +80,7 @@ export function NotificationPreferencesCard({ onChange={save} busy={fetcher.state !== "idle"} status={status} + onBulk={bulk} /> )} diff --git a/app/hooks/useNotificationSaveToast.ts b/app/hooks/useNotificationSaveToast.ts new file mode 100644 index 000000000..6eb614fb6 --- /dev/null +++ b/app/hooks/useNotificationSaveToast.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef } from "react"; +import { pushToast } from "~/hooks/useToast"; +import { m } from "~/paraglide/messages"; + +/** + * Announce the result of a preference write, once per completed write. + * + * Three surfaces run the same fetcher against three different routes, and each + * would otherwise grow its own copy of "did that land". They already disagreed + * once about something smaller (which shape counts as an error), which is + * exactly how one of them ends up silently swallowing a failure. + * + * FAILURE IS A TOAST, and that is the part that matters. The inline red line it + * replaces sat inside a card the reader may well have scrolled past — a message + * about mail they will not receive, placed where they cannot see it. Success is + * a toast too, for consistency with the rest of the app; the in-flight state + * stays inline next to the control, because "saving" is about the thing you + * just touched and a toast would be pointing at the wrong place. + */ +export function useNotificationSaveToast({ + data, failed, error, +}: { + /** The fetcher payload. A new object identity means a write completed. */ + data: unknown; + failed: boolean; + error?: string | null; +}) { + // Keyed on identity, not on a boolean: two failures in a row are two + // events, and a flag would announce only the first. + const seen = useRef(null); + useEffect(() => { + if (!data || data === seen.current) return; + seen.current = data; + // A failure gets longer on screen than a confirmation: "Saved" is a + // glance, but a failure asks the reader to decide whether to try again. + // `String(...)` unifies paraglide's branded LocalizedString with the + // plain string the queue takes. + pushToast(failed + ? { message: String(error ?? m.notif_prefs_save_failed()), variant: "error", durationMs: 6000 } + : { message: String(m.notif_prefs_saved()), variant: "success", durationMs: 2500 }); + }, [data, failed, error]); +} diff --git a/app/lib/portal-notification-preferences.ts b/app/lib/portal-notification-preferences.ts index efae9b228..d71b80136 100644 --- a/app/lib/portal-notification-preferences.ts +++ b/app/lib/portal-notification-preferences.ts @@ -72,3 +72,27 @@ export async function savePortalNotificationChoice( ); return res.ok ? { ok: true } : { ok: false, error: m.portal_notif_save_error() }; } + +/** A whole row, column or the entire grid, in one request. */ +export async function bulkPortalNotificationChoice( + context: LoadContext, + tenant: string, + cookie: string, + formData: FormData, +): Promise<{ ok: boolean; error?: string }> { + const api = createApi(context); + const channel = String(formData.get("channel") ?? ""); + const classId = String(formData.get("classId") ?? ""); + const res = await api.portalNotificationPrefs[":tenant"]["notification-preferences"].bulk.$put( + { + param: { tenant }, + json: { + action: String(formData.get("action") ?? "enable") as "enable" | "disable" | "reset", + ...(channel ? { channel: channel as "email" | "sms" | "in_app" } : {}), + ...(classId ? { classId } : {}), + }, + }, + { headers: { Cookie: cookie } }, + ); + return res.ok ? { ok: true } : { ok: false, error: m.portal_notif_save_error() }; +} diff --git a/app/lib/section-loaders.ts b/app/lib/section-loaders.ts index 7f7b89b48..c3a98f811 100644 --- a/app/lib/section-loaders.ts +++ b/app/lib/section-loaders.ts @@ -38,16 +38,29 @@ import type { LoadContext } from "~/lib/load-context"; /* Section validation */ /* ------------------------------------------------------------------ */ -const HUB_SECTIONS: HubSection[] = [ - "overview", - "report", - "agreement", - "payment", - "progress", - "messages", - "repair", - "documents", -]; +/** + * Every Hub section, as data. + * + * A `Record` rather than an array, so the COMPILER is what + * keeps this in sync with the union: adding a member to `HubSection` without + * adding it here is a type error, not a section that silently falls back to + * the overview. That is exactly how `notifications` shipped unreachable — the + * type had it, this list did not, and nothing said so (CLAUDE.md: make a + * "must stay in sync" coupling executable, not a comment). + */ +const HUB_SECTION_SET: Record = { + overview: true, + report: true, + agreement: true, + payment: true, + progress: true, + messages: true, + repair: true, + documents: true, + notifications: true, +}; + +const HUB_SECTIONS = Object.keys(HUB_SECTION_SET) as HubSection[]; export function parseSection(v: string | null): HubSection { return v !== null && (HUB_SECTIONS as string[]).includes(v) ? (v as HubSection) : "overview"; diff --git a/app/lib/settings-notifications.server.ts b/app/lib/settings-notifications.server.ts index a235fc0bb..19e4afaba 100644 --- a/app/lib/settings-notifications.server.ts +++ b/app/lib/settings-notifications.server.ts @@ -78,3 +78,28 @@ export async function saveNotificationChoice( ? { success: true, error: null } : { success: false, error: m.settings_notifications_error() }; } + +/** + * A whole row, column or the entire grid. + * + * Separate from `saveNotificationChoice` because it is a different request, not + * a loop over the single-cell one: N round trips would leave the screen half + * changed if any of them failed, and the reader would have no way to tell which. + */ +export async function bulkNotificationChoice( + api: Api, + fd: FormData, +): Promise<{ success: boolean; error: string | null }> { + const channel = String(fd.get("channel") ?? ""); + const classId = String(fd.get("classId") ?? ""); + const res = await api.notificationPrefs["notification-preferences"].bulk.$put({ + json: { + action: String(fd.get("action") ?? "enable") as "enable" | "disable" | "reset", + ...(channel ? { channel: channel as "email" | "sms" | "in_app" } : {}), + ...(classId ? { classId } : {}), + }, + }); + return res.ok + ? { success: true, error: null } + : { success: false, error: m.settings_notifications_error() }; +} diff --git a/app/routes/agent/settings-profile.tsx b/app/routes/agent/settings-profile.tsx index 874fcb0f9..8a6d1c53a 100644 --- a/app/routes/agent/settings-profile.tsx +++ b/app/routes/agent/settings-profile.tsx @@ -13,6 +13,7 @@ import { type ChoiceRow, } from "~/components/notifications/NotificationPreferences"; import { TIMEZONE_SELECT_OPTIONS } from "~/lib/timezones"; +import { useNotificationSaveToast } from "~/hooks/useNotificationSaveToast"; import { m } from "~/paraglide/messages"; export function meta() { @@ -87,7 +88,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { return { agent: profile, notifications }; } -type ActionIntent = "save-slug" | "save-notifications" | "save-timezone"; +type ActionIntent = "save-slug" | "save-notifications" | "bulk-notifications" | "save-timezone"; export async function action({ request, context }: Route.ActionArgs) { const token = await requireToken(context, request); @@ -115,6 +116,22 @@ export async function action({ request, context }: Route.ActionArgs) { return toActionResult(res, "save-notifications" as const, m.agent_portal_settings_notify_error_generic()); } + if (intent === "bulk-notifications") { + const scope = fd.get("scope") === "all" ? ("all" as const) : ("company" as const); + const channel = String(fd.get("channel") ?? ""); + const classId = String(fd.get("classId") ?? ""); + const res = await api.agentNotificationPrefs["notification-preferences"].bulk.$put({ + json: { + action: String(fd.get("action") ?? "enable") as "enable" | "disable" | "reset", + ...(channel ? { channel: channel as ChannelId } : {}), + ...(classId ? { classId } : {}), + scope, + ...(scope === "company" ? { companyId: String(fd.get("companyId") ?? "") } : {}), + }, + }); + return toActionResult(res, "bulk-notifications" as const, m.agent_portal_settings_notify_error_generic()); + } + if (intent === "save-timezone") { // Empty string clears the override (server persists NULL → per-company tz). const timezone = String(fd.get("timezone") ?? ""); @@ -134,14 +151,14 @@ export default function AgentSettingsProfilePage() { const slugError = slugResult && !slugResult.ok ? slugResult.error : null; const notifyFetcher = useFetcher(); - const notifyResult = notifyFetcher.data?.intent === "save-notifications" ? notifyFetcher.data : null; + const notifyResult = notifyFetcher.data?.intent === "save-notifications" + || notifyFetcher.data?.intent === "bulk-notifications" ? notifyFetcher.data : null; const notifyError = notifyResult && !notifyResult.ok ? notifyResult.error : null; + useNotificationSaveToast({ data: notifyResult, failed: !!notifyError, error: notifyError }); const [applyAll, setApplyAll] = useState(false); // "saved" persists after the fetcher goes idle, so the confirmation is still // on screen when the reader looks up from the switch they just moved. - const notifyStatus = notifyFetcher.state !== "idle" ? "saving" as const - : notifyError ? "idle" as const - : notifyFetcher.data ? "saved" as const : "idle" as const; + const notifyStatus = notifyFetcher.state !== "idle" ? "saving" as const : "idle" as const; const navigate = useNavigate(); const tzFetcher = useFetcher(); @@ -177,6 +194,20 @@ export default function AgentSettingsProfilePage() { ); } + function bulkNotification(enabled: boolean, scope: { channel?: ChannelId; classId?: string }) { + notifyFetcher.submit( + { + intent: "bulk-notifications", + action: enabled ? "enable" : "disable", + ...(scope.channel ? { channel: scope.channel } : {}), + ...(scope.classId ? { classId: scope.classId } : {}), + scope: applyAll ? "all" : "company", + companyId: notifications.selected ?? "", + }, + { method: "post" }, + ); + } + function selectCompany(id: string) { // A full navigation, not local state: the whole card is that company's // answer, and the URL is what makes a reader's place in it shareable and @@ -231,9 +262,6 @@ export default function AgentSettingsProfilePage() {

{m.agent_portal_settings_notifications_desc()}

- {notifyError && ( -

{notifyError}

- )} {notifications.error ? (

{notifications.error}

@@ -268,6 +296,7 @@ export default function AgentSettingsProfilePage() { onChange={saveNotification} busy={notifyFetcher.state !== "idle"} status={notifyStatus} + onBulk={bulkNotification} /> diff --git a/app/routes/public/portal-inspection.tsx b/app/routes/public/portal-inspection.tsx index 2a1740d54..d4a9f64b1 100644 --- a/app/routes/public/portal-inspection.tsx +++ b/app/routes/public/portal-inspection.tsx @@ -53,6 +53,7 @@ import { type AgreementLoaderResult, } from "~/lib/section-loaders"; import { + bulkPortalNotificationChoice, loadNotificationsSection, savePortalNotificationChoice, type NotificationsLoaderResult, @@ -251,6 +252,13 @@ export async function action({ request, params, context }: Route.ActionArgs) { // C3 — the Notices bell's writes. The session cookie travels explicitly // because the typed client does not forward the browser's. + if (intent === "notification-bulk") { + const r = await bulkPortalNotificationChoice( + context, tenant, request.headers.get("cookie") ?? "", formData, + ); + return { ...r, intent }; + } + if (intent === "notification-preference") { const r = await savePortalNotificationChoice( context, tenant, request.headers.get("cookie") ?? "", formData, diff --git a/app/routes/settings-profile.tsx b/app/routes/settings-profile.tsx index 55e6e13a0..160ed5ee3 100644 --- a/app/routes/settings-profile.tsx +++ b/app/routes/settings-profile.tsx @@ -18,7 +18,7 @@ import { LOCALE_OPTIONS } from "~/lib/locales"; import { SectionNav } from "~/components/settings/SectionNav"; import { CredentialsEditor, type EditorCredential } from "~/components/settings/CredentialsEditor"; import { NotificationPreferencesCard } from "~/components/settings/NotificationPreferencesCard"; -import { loadNotificationScreen, saveNotificationChoice } from "~/lib/settings-notifications.server"; +import { bulkNotificationChoice, loadNotificationScreen, saveNotificationChoice } from "~/lib/settings-notifications.server"; import { m } from "~/paraglide/messages"; /* ------------------------------------------------------------------ */ @@ -69,6 +69,10 @@ export async function action({ request, context }: Route.ActionArgs) { return { ...(await saveNotificationChoice(api, fd)), intent }; } + if (intent === "bulk-notification") { + return { ...(await bulkNotificationChoice(api, fd)), intent }; + } + // Handle save-signature intent from the SignaturePad fetcher if (intent === "save-signature") { const signatureBase64 = fd.get("signatureBase64") as string | null; diff --git a/messages/en/components.json b/messages/en/components.json index 0ce235159..fe1152b0b 100644 --- a/messages/en/components.json +++ b/messages/en/components.json @@ -153,6 +153,12 @@ "notif_prefs_choose_heading": "You choose", "notif_prefs_saving": "Saving…", "notif_prefs_saved": "Saved", + "notif_prefs_bulk_all": "Turn every notification on or off", + "notif_prefs_bulk_all_short": "All", + "notif_prefs_bulk_column": "Turn {channel} on or off for every notification", + "notif_prefs_bulk_row": "Turn {notification} on or off on every channel", + "notif_prefs_save_failed": "Couldn't save that. Please try again.", + "notif_prefs_legend": "A dash means we don't send that notification on that channel, so there's nothing to switch.", "notif_prefs_choose_empty": "Nothing here yet. When there is something you can switch off, it will appear here.", "notif_prefs_channel_email": "Email", "notif_prefs_channel_sms": "Text", diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 265084cf2..7e7c9497e 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -36,8 +36,8 @@ "server/services/inspection/inspection-photo.service.ts": 531, "app/components/NewInspectionWizard.tsx": 530, "server/api/inspections/media-studio.ts": 530, + "app/routes/settings-profile.tsx": 525, "server/services/portal-access.service.ts": 525, - "app/routes/settings-profile.tsx": 521, "server/api/inspections/publish.ts": 516, "server/api/portal.ts": 515, "app/components/settings/ManagedComplianceWizard.tsx": 514, @@ -59,8 +59,9 @@ "app/components/media-studio/VideoCapture.tsx": 433, "server/api/inspections/results.ts": 430, "app/hooks/useStructureEdit.ts": 424, + "app/routes/public/portal-inspection.tsx": 424, "server/lib/middleware/di.ts": 417, - "app/routes/public/portal-inspection.tsx": 416, "app/routes/templates.tsx": 414, - "app/routes/calendar.tsx": 410 + "app/routes/calendar.tsx": 410, + "app/lib/section-loaders.ts": 402 } diff --git a/server/api/agent/notification-preferences.ts b/server/api/agent/notification-preferences.ts index cf6dce68d..d18b9c4fb 100644 --- a/server/api/agent/notification-preferences.ts +++ b/server/api/agent/notification-preferences.ts @@ -4,7 +4,7 @@ import { requireRole } from '../../lib/middleware/rbac'; import { withMcpMetadata } from '../../lib/route-metadata-standards'; import { getDrizzle } from '../../lib/route-helpers'; import { buildScreenModel } from '../../lib/notifications/screen-model'; -import { assertChoosable, readChoices, writeChoice } from '../../lib/notifications/preference-write'; +import { applyBulk, assertChoosable, readChoices, writeChoice } from '../../lib/notifications/preference-write'; import { listAgentCompanies } from '../../services/agent/companies'; import { Errors } from '../../lib/errors'; @@ -61,6 +61,37 @@ const SaveSchema = z.object({ .describe('"all" applies the choice to every company currently linked to this agent.'), }); +const BulkSchema = z.object({ + action: z.enum(['enable', 'disable', 'reset']) + .describe('enable/disable every cell in scope; reset clears them back to defaults.'), + channel: z.enum(['email', 'sms', 'in_app']).optional().describe('Limit to one channel (a column).'), + classId: z.string().optional().describe('Limit to one notification (a row).'), + companyId: z.string().optional().describe('Tenant id to apply this to. Required unless scope is "all".'), + scope: z.enum(['company', 'all']).optional().describe('"all" applies to every linked company.'), +}); + +const bulkRoute = createRoute(withMcpMetadata({ + method: 'put', + path: '/notification-preferences/bulk', + tags: ['agents'], + summary: 'Change a whole row, column or grid at a company', + request: { body: { content: { 'application/json': { schema: BulkSchema } } } }, + responses: { + 200: { + content: { 'application/json': { schema: z.object({ success: z.literal(true), applied: z.number() }) } }, + description: 'Applied. `applied` counts the companies it was written for.', + }, + 400: { description: 'A company this agent is not bound to' }, + 401: { description: 'Unauthorized' }, + }, + security: [{ bearerAuth: [] }], + operationId: 'bulkSaveAgentNotificationPreferences', + description: + 'Applies one action to every cell in scope at one company, or at every company linked ' + + 'to this agent. Channels a notification never uses are skipped, and always-sent ' + + 'notifications are never touched.', +}, { scopes: ['agent'], tier: 'extended' })); + const getScreenRoute = createRoute(withMcpMetadata({ method: 'get', path: '/notification-preferences', @@ -160,6 +191,29 @@ const agentNotificationPreferenceRoutes = createApiRouter() }); } return c.json({ success: true as const, applied: targets.length }, 200); + }) + .openapi(bulkRoute, async (c) => { + await requireRole('agent')(c, async () => {}); + const agentUserId = c.get('user').sub; + const { action, channel, classId, companyId, scope } = c.req.valid('json'); + + const db = getDrizzle(c); + const companies = await listAgentCompanies(db, agentUserId); + const targets = scope === 'all' + ? companies + : companies.filter((x) => x.tenantId === companyId); + if (targets.length === 0) { + throw Errors.BadRequest('You are not currently linked to that company.'); + } + for (const t of targets) { + await applyBulk( + db, + { tenantId: t.tenantId, subjectKind: 'contact', subjectId: t.contactId }, + 'agent', + { action, ...(channel ? { channel } : {}), ...(classId ? { classId } : {}) }, + ); + } + return c.json({ success: true as const, applied: targets.length }, 200); }); export default agentNotificationPreferenceRoutes; diff --git a/server/api/notification-preferences.ts b/server/api/notification-preferences.ts index 2ec12a12a..d40df1210 100644 --- a/server/api/notification-preferences.ts +++ b/server/api/notification-preferences.ts @@ -3,7 +3,7 @@ import { createApiRouter } from '../lib/openapi-router'; import { withMcpMetadata } from '../lib/route-metadata-standards'; import { getDrizzle } from '../lib/route-helpers'; import { buildScreenModel } from '../lib/notifications/screen-model'; -import { assertChoosable, readChoices, writeChoice } from '../lib/notifications/preference-write'; +import { applyBulk, assertChoosable, readChoices, writeChoice } from '../lib/notifications/preference-write'; /** * The signed-in reader's own notification preferences (spec §4). @@ -42,6 +42,14 @@ const ScreenResponseSchema = z.object({ }), }).openapi('NotificationPreferencesScreen'); +const BulkSchema = z.object({ + action: z.enum(['enable', 'disable', 'reset']) + .describe('enable/disable every cell in scope; reset clears them back to defaults.'), + channel: z.enum(['email', 'sms', 'in_app']).optional() + .describe('Limit to one channel (a column). Omit with classId for everything.'), + classId: z.string().optional().describe('Limit to one notification (a row).'), +}); + const SaveSchema = z.object({ classId: z.string().describe('The notification class being changed, e.g. review-request.'), channel: ChannelSchema.describe('Which channel this choice applies to: email, sms or in_app.'), @@ -85,6 +93,25 @@ const saveRoute = createRoute(withMcpMetadata({ 'storing a row that restates the default. A class that is always sent is refused.', }, { scopes: ['write'], tier: 'extended' })); +const bulkRoute = createRoute(withMcpMetadata({ + method: 'put', + path: '/notification-preferences/bulk', + tags: ['notifications'], + summary: 'Change a whole row, column or the entire grid', + request: { body: { content: { 'application/json': { schema: BulkSchema } } } }, + responses: { + 200: { + content: { 'application/json': { schema: z.object({ success: z.literal(true), stored: z.number() }) } }, + description: 'Applied. `stored` counts rows that differ from the default.', + }, + }, + operationId: 'bulkSaveNotificationPreferences', + description: + 'Applies one action to every cell in scope: a row (one notification), a column (one ' + + 'channel), or everything. Channels a notification never uses are skipped, and ' + + 'always-sent notifications are never touched.', +}, { scopes: ['write'], tier: 'extended' })); + const notificationPreferenceRoutes = createApiRouter() .openapi(getScreenRoute, async (c) => { const tenantId = c.get('tenantId') as string; @@ -111,6 +138,15 @@ const notificationPreferenceRoutes = createApiRouter() tenantId, subjectKind: 'user', subjectId: userId, classId, channel, enabled, }); return c.json({ success: true as const }, 200); + }) + .openapi(bulkRoute, async (c) => { + const tenantId = c.get('tenantId') as string; + const userId = c.get('user')?.sub as string; + const change = c.req.valid('json'); + const stored = await applyBulk( + getDrizzle(c), { tenantId, subjectKind: 'user', subjectId: userId }, 'staff', change, + ); + return c.json({ success: true as const, stored }, 200); }); export default notificationPreferenceRoutes; diff --git a/server/api/portal/notification-preferences.ts b/server/api/portal/notification-preferences.ts index 61a94e7ae..e0c923273 100644 --- a/server/api/portal/notification-preferences.ts +++ b/server/api/portal/notification-preferences.ts @@ -6,7 +6,7 @@ import { portalSessionGuard } from '../../lib/middleware/portal-session-guard'; import { getDrizzle } from '../../lib/route-helpers'; import type { HonoConfig } from '../../types/hono'; import { buildScreenModel } from '../../lib/notifications/screen-model'; -import { assertChoosable, readChoices, writeChoice } from '../../lib/notifications/preference-write'; +import { applyBulk, assertChoosable, readChoices, writeChoice } from '../../lib/notifications/preference-write'; import { contactIdsForEmail } from '../../services/notice-inbox'; import { Errors } from '../../lib/errors'; @@ -51,6 +51,13 @@ const SaveSchema = z.object({ enabled: z.boolean().describe('True to receive it again; false to switch it off.'), }); +const BulkSchema = z.object({ + action: z.enum(['enable', 'disable', 'reset']) + .describe('enable/disable every cell in scope; reset clears them back to defaults.'), + channel: z.enum(['email', 'sms', 'in_app']).optional().describe('Limit to one channel (a column).'), + classId: z.string().optional().describe('Limit to one notification (a row).'), +}); + function resolveTenantId(c: Context): string | null { return c.get('tenantId') || c.get('resolvedTenantId') || null; } @@ -99,8 +106,32 @@ const saveRoute = createRoute(withMcpMetadata({ 'tenant. A choice that matches the class default deletes the row rather than storing it.', }, { scopes: [], tier: 'extended' })); +const bulkRoute = createRoute(withMcpMetadata({ + method: 'put', + path: '/{tenant}/notification-preferences/bulk', + tags: ['public'], + summary: 'Change a whole row, column or the entire grid', + request: { + params: TenantParam, + body: { content: { 'application/json': { schema: BulkSchema } } }, + }, + responses: { + 200: { + content: { 'application/json': { schema: z.object({ success: z.literal(true) }) } }, + description: 'Applied.', + }, + 401: { description: 'No valid portal session cookie' }, + }, + operationId: 'portalBulkSaveNotificationPreferences', + description: + 'Applies one action to every cell in scope, against every contact row this session ' + + 'resolves to. Channels a notification never uses are skipped, and always-sent ' + + 'notifications are never touched.', +}, { scopes: [], tier: 'extended' })); + const router = createApiRouter(); router.use('/:tenant/notification-preferences', portalSessionGuard); +router.use('/:tenant/notification-preferences/bulk', portalSessionGuard); const portalNotificationPreferenceRoutes = router .openapi(getScreenRoute, async (c) => { @@ -142,6 +173,19 @@ const portalNotificationPreferenceRoutes = router }); } return c.json({ success: true as const }, 200); + }) + .openapi(bulkRoute, async (c) => { + const tenantId = resolveTenantId(c); + if (!tenantId) throw Errors.NotFound('Company not found.'); + const change = c.req.valid('json'); + + const db = getDrizzle(c); + const contactIds = await contactIdsForEmail(db, tenantId, c.get('portalEmail') as string); + if (contactIds.length === 0) throw Errors.BadRequest('There is nothing to change here.'); + for (const subjectId of contactIds) { + await applyBulk(db, { tenantId, subjectKind: 'contact', subjectId }, 'client', change); + } + return c.json({ success: true as const }, 200); }); export default portalNotificationPreferenceRoutes; diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json index aebbe1f45..3c5b9022f 100644 --- a/server/lib/mcp/openapi-snapshot.json +++ b/server/lib/mcp/openapi-snapshot.json @@ -633,6 +633,108 @@ "summary": "Bulk inspection for current tenant", "description": "Perform mass operations on multiple inspections. (PATCH /bulk, inspections domain)." }, + { + "operationId": "bulkSaveAgentNotificationPreferences", + "method": "PUT", + "pathTemplate": "/api/agent/notification-preferences/bulk", + "scopes": [ + "agent" + ], + "tag": "agents", + "tier": "extended", + "inputSchema": { + "parameters": [], + "body": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "enable", + "disable", + "reset" + ], + "description": "enable/disable every cell in scope; reset clears them back to defaults." + }, + "channel": { + "type": "string", + "enum": [ + "email", + "sms", + "in_app" + ], + "description": "Limit to one channel (a column)." + }, + "classId": { + "type": "string", + "description": "Limit to one notification (a row)." + }, + "companyId": { + "type": "string", + "description": "Tenant id to apply this to. Required unless scope is \"all\"." + }, + "scope": { + "type": "string", + "enum": [ + "company", + "all" + ], + "description": "\"all\" applies to every linked company." + } + }, + "required": [ + "action" + ] + } + }, + "summary": "Change a whole row, column or grid at a company", + "description": "Applies one action to every cell in scope at one company, or at every company linked to this agent. Channels a notification never uses are skipped, and always-sent notifications are never touched." + }, + { + "operationId": "bulkSaveNotificationPreferences", + "method": "PUT", + "pathTemplate": "/api/notification-preferences/bulk", + "scopes": [ + "write" + ], + "tag": "notifications", + "tier": "extended", + "inputSchema": { + "parameters": [], + "body": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "enable", + "disable", + "reset" + ], + "description": "enable/disable every cell in scope; reset clears them back to defaults." + }, + "channel": { + "type": "string", + "enum": [ + "email", + "sms", + "in_app" + ], + "description": "Limit to one channel (a column). Omit with classId for everything." + }, + "classId": { + "type": "string", + "description": "Limit to one notification (a row)." + } + }, + "required": [ + "action" + ] + } + }, + "summary": "Change a whole row, column or the entire grid", + "description": "Applies one action to every cell in scope: a row (one notification), a column (one channel), or everything. Channels a notification never uses are skipped, and always-sent notifications are never touched." + }, { "operationId": "cancelInspection", "method": "POST", @@ -14398,6 +14500,60 @@ "summary": "Dismiss (archive) a notice", "description": "Archives the notice for this recipient. Never a row deletion, and never a write to automation_logs: a recipient tidying their own inbox cannot edit the sending company's delivery record, which the inspector's Outbox keeps forever." }, + { + "operationId": "portalBulkSaveNotificationPreferences", + "method": "PUT", + "pathTemplate": "/api/portal/{tenant}/notification-preferences/bulk", + "scopes": [], + "tag": "public", + "tier": "extended", + "inputSchema": { + "parameters": [ + { + "name": "tenant", + "in": "path", + "required": true, + "description": "Tenant slug (resolves the tenant from the URL path).", + "schema": { + "type": "string", + "description": "Tenant slug (resolves the tenant from the URL path)." + } + } + ], + "body": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "enable", + "disable", + "reset" + ], + "description": "enable/disable every cell in scope; reset clears them back to defaults." + }, + "channel": { + "type": "string", + "enum": [ + "email", + "sms", + "in_app" + ], + "description": "Limit to one channel (a column)." + }, + "classId": { + "type": "string", + "description": "Limit to one notification (a row)." + } + }, + "required": [ + "action" + ] + } + }, + "summary": "Change a whole row, column or the entire grid", + "description": "Applies one action to every cell in scope, against every contact row this session resolves to. Channels a notification never uses are skipped, and always-sent notifications are never touched." + }, { "operationId": "portalExchangeToken", "method": "GET", diff --git a/server/lib/notifications/preference-write.ts b/server/lib/notifications/preference-write.ts index 4b5a91580..da736781c 100644 --- a/server/lib/notifications/preference-write.ts +++ b/server/lib/notifications/preference-write.ts @@ -2,6 +2,7 @@ import { and, eq } from 'drizzle-orm'; import { nanoid } from 'nanoid'; import { notificationPreferences } from '../db/schema'; import { defaultEnabled, isSuppressible, notificationClass, type Audience } from './classes'; +import { classesFor } from './screen-model'; import { Errors } from '../errors'; /** @@ -102,3 +103,76 @@ export async function readChoices( return new Map(rows.map((r: { classId: string; channel: string; enabled: boolean }) => [`${r.classId}:${r.channel}`, r.enabled])); } + +/** + * A bulk change, scoped the way the GRID is scoped. + * + * The screen is notifications x channels, so the useful bulk actions are the + * ones that match its shape: one row (every channel of one notification), one + * column (one channel across every notification), or the corner (everything). + * Three loose buttons above the table would have made the reader work out which + * cells each one touched; a control that sits ON the row or column says it. + */ +export interface BulkChange { + /** `reset` DELETES the rows in scope so each falls back to its default. */ + action: 'enable' | 'disable' | 'reset'; + /** Limit to one channel (a column). */ + channel?: 'email' | 'sms' | 'in_app' | undefined; + /** Limit to one notification (a row). */ + classId?: string | undefined; +} + +/** + * Apply one bulk change to the cells this reader can actually choose. + * + * `reset` is NOT `enable`, and the difference is load-bearing: reset deletes + * rows so every class returns to its own default — and one class defaults to + * OFF (`agent-invoice-paid`, whose column defaulted to false). Treating them as + * synonyms would silently switch that one on. + * + * The cells it touches come from `classesFor(audience)` intersected with each + * class's own channel list, so a bulk change can never reach a class this + * reader is not addressed by, a class that is always sent, or a channel the + * class never uses — the three refusals `assertChoosable` makes one at a time, + * made structural instead. A row's `unavailable` cells are skipped rather than + * switched on, which is the whole reason the em dash is not a control. + */ +export async function applyBulk( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + db: any, + subject: { tenantId: string; subjectKind: 'user' | 'contact'; subjectId: string }, + audience: Audience, + change: BulkChange, +): Promise { + const targets = classesFor(audience) + .filter((c) => !c.required) + .filter((c) => !change.classId || c.id === change.classId) + .flatMap((c) => c.channels + .filter((ch) => !change.channel || ch === change.channel) + .map((ch) => ({ cls: c, channel: ch }))); + + if (change.action === 'reset') { + // Delete only the cells in scope, so resetting one row leaves the rest + // of the reader's decisions alone. + for (const t of targets) { + await db.delete(notificationPreferences).where(and( + eq(notificationPreferences.tenantId, subject.tenantId), + eq(notificationPreferences.subjectKind, subject.subjectKind), + eq(notificationPreferences.subjectId, subject.subjectId), + eq(notificationPreferences.classId, t.cls.id), + eq(notificationPreferences.channel, t.channel), + )).run(); + } + return 0; + } + + const enabled = change.action === 'enable'; + let written = 0; + for (const t of targets) { + await writeChoice(db, { ...subject, classId: t.cls.id, channel: t.channel, enabled }); + // `writeChoice` stores only what differs from the default, so this + // counts the DECISIONS recorded, not the switches moved. + if (enabled !== defaultEnabled(t.cls.id)) written++; + } + return written; +} diff --git a/server/lib/sms/send-gate.ts b/server/lib/sms/send-gate.ts index 8d0161907..b836c4ce7 100644 --- a/server/lib/sms/send-gate.ts +++ b/server/lib/sms/send-gate.ts @@ -29,6 +29,7 @@ import { normalizeE164 } from './phone'; import type { RoleKind } from '../people/role-kinds'; import type { PlanQuotaGuard } from '../../features/plan-quota/guard'; import { logger } from '../logger'; +import { isPreferenceMuted } from '../notifications/preference-port'; /** * Why this message is being sent — and therefore which gates it is exempt from. @@ -71,6 +72,19 @@ export interface SmsGateArgs { /** Consent basis for the recipient. Only consulted when `purpose` is `notification`. */ roleKind?: RoleKind; env?: ManagedSendGateEnv | undefined; + /** + * WHAT is being sent — a `NOTIFICATION_CLASSES` id. + * + * Without it this gate cannot consult the recipient's own preference, and + * the screen grows a text switch that writes a row nothing reads. Absent ⇒ + * the send is UNCLASSIFIED and therefore never muted (`isSuppressible` + * fails closed), which is the right answer for an admin test send. + * + * A preference can only ever NARROW what consent already allows (§3.3): + * it is checked AFTER consent, never instead of it, so muting a class can + * never turn an un-consented number into a sendable one. + */ + classId?: string | undefined; /** Absent ⇒ no quota enforcement (standalone, BYO, or a non-quota deployment). */ quota?: { guard: PlanQuotaGuard; tier: string } | undefined; } @@ -110,7 +124,7 @@ async function contactIdsForPhone( } export async function smsSendGate(args: SmsGateArgs): Promise { - const { db, tenantId, to, purpose, contactId, roleKind, env, quota } = args; + const { db, tenantId, to, purpose, contactId, roleKind, env, quota, classId } = args; // A tenant with no config row is 'platform' — the same default all three // chains already used. Wrapped rather than `.catch()`-chained because some @@ -154,6 +168,19 @@ export async function smsSendGate(args: SmsGateArgs): Promise { } } + // ── The recipient's own preference, AFTER consent and BEFORE quota. + // + // After consent because a preference narrows what consent allows and must + // never widen it. Before quota because a text nobody wanted must not spend + // the tenant's allowance — the same ordering the email boundary uses. + if (classId && consultable.length > 0) { + const muted = await isPreferenceMuted( + db, tenantId, classId, 'sms', + consultable.map((id) => ({ kind: 'contact' as const, id })), + ).catch(() => false); // Fail OPEN: a failed lookup must not silence a send. + if (muted) return { allowed: false, reason: 'recipient switched this off' }; + } + const gate = await managedSendAllowed(db, env ?? {}, tenantId, smsMode); if (!gate.allowed) { logger.info('[sms-gate] blocked by managed compliance gate', { tenantId, reason: gate.reason }); diff --git a/server/services/automation/send-one-sms.ts b/server/services/automation/send-one-sms.ts index fc26b6ee0..7f8d62308 100644 --- a/server/services/automation/send-one-sms.ts +++ b/server/services/automation/send-one-sms.ts @@ -62,6 +62,15 @@ export type SendOneSmsArgs = { tenant: typeof tenants.$inferSelect; /** Already-resolved SMS body template (may contain `{{vars}}`). */ bodyTemplate: string; + /** + * The notification class this rule sends, when it is a seeded one. + * + * Resolved by the CALLER because that is where the rule is — this function + * only ever sees the log. A tenant-written rule has no seeded class and so + * stays unclassified and unmutable, which is `isSuppressible` failing + * closed rather than an oversight. + */ + classId?: string | undefined; sms: SmsProviderSeam; appName: string; appHost: string; @@ -108,6 +117,7 @@ async function resolveRecipientRoleKind( } export async function sendOneSms(args: SendOneSmsArgs): Promise { + const { classId } = args; const { db, log, inspection, tenant, bodyTemplate, sms, appName, appHost, env, quotaGuard, metering, @@ -146,6 +156,9 @@ export async function sendOneSms(args: SendOneSmsArgs): Promise { contactId, roleKind, env, + // Lets the gate consult this recipient's own preference. Without it the + // screen grows a text switch that writes a row nothing reads. + ...(classId ? { classId } : {}), ...(quotaGuard ? { quota: { guard: quotaGuard, tier: tenant.tier } } : {}), }); if (!gate.allowed) return void (await skip(gate.reason)); diff --git a/server/services/automation/sms.ts b/server/services/automation/sms.ts index aa50bcc3f..3d59074ed 100644 --- a/server/services/automation/sms.ts +++ b/server/services/automation/sms.ts @@ -10,6 +10,7 @@ */ import type { DrizzleD1Database } from 'drizzle-orm/d1'; import type { automations, tenants } from '../../lib/db/schema'; +import { automationClassId } from '../../lib/notifications/automation-classes'; import { automationLogs } from '../../lib/db/schema'; import { eq, and } from 'drizzle-orm'; import type { Constructor, FlushInspection } from './shared'; @@ -67,6 +68,8 @@ export function AutomationSms>(Base: T inspection, tenant, bodyTemplate: tpl.body, + // The rule lives here, so the class is resolved here. + ...(automationClassId(automation) ? { classId: automationClassId(automation)! } : {}), sms, appName, appHost, diff --git a/tests/unit/messaging/sms-send-gate.spec.ts b/tests/unit/messaging/sms-send-gate.spec.ts index 149422e22..e3f95a12c 100644 --- a/tests/unit/messaging/sms-send-gate.spec.ts +++ b/tests/unit/messaging/sms-send-gate.spec.ts @@ -174,3 +174,56 @@ describe('smsSendGate — managed compliance', () => { }); }); }); + +describe('smsSendGate — the recipient’s own preference', () => { + /** + * The screen renders a Text switch for `booking-confirmation` and + * `inspection-reminder`. Until this gate consulted preferences, ticking it + * off stored a row that NOTHING read and the text went out anyway — a + * screen that accepts a change and then ignores it, which is the exact + * defect the preference layer exists to remove. + */ + async function mute(classId: string, contactId = 'c1') { + await db.insert(schema.notificationPreferences).values({ + id: `np-${classId}-${contactId}`, tenantId: TENANT, subjectKind: 'contact', + subjectId: contactId, classId, channel: 'sms', enabled: false, + createdAt: new Date(), updatedAt: new Date(), + } as never); + } + + it('withholds a class this recipient switched off', async () => { + await seedContact('c1', PHONE); + await seedConsent('sc1', 'c1', 'granted'); + await mute('booking-confirmation'); + + const r = await gate({ contactId: 'c1', roleKind: 'client', classId: 'booking-confirmation' }); + expect(r.allowed).toBe(false); + }); + + it('sends a DIFFERENT class the same recipient did not switch off', async () => { + await seedContact('c1', PHONE); + await seedConsent('sc1', 'c1', 'granted'); + await mute('booking-confirmation'); + + const r = await gate({ contactId: 'c1', roleKind: 'client', classId: 'inspection-reminder' }); + expect(r.allowed).toBe(true); + }); + + it('sends an UNCLASSIFIED message — an admin test send is not mutable', async () => { + await seedContact('c1', PHONE); + await seedConsent('sc1', 'c1', 'granted'); + await mute('booking-confirmation'); + + const r = await gate({ contactId: 'c1', roleKind: 'client' }); + expect(r.allowed).toBe(true); + }); + + it('a preference NARROWS consent, it never widens it', async () => { + // §3.3 — consent is the authority on this channel. A recipient who + // never granted consent stays unreachable no matter what the + // preference table says, so the order of the two checks is load-bearing. + await seedContact('c1', PHONE); + const r = await gate({ contactId: 'c1', roleKind: 'client', classId: 'inspection-reminder' }); + expect(r).toEqual({ allowed: false, reason: 'no sms consent' }); + }); +}); diff --git a/tests/unit/notifications/preference-bulk.spec.ts b/tests/unit/notifications/preference-bulk.spec.ts new file mode 100644 index 000000000..f3f6cb11a --- /dev/null +++ b/tests/unit/notifications/preference-bulk.spec.ts @@ -0,0 +1,122 @@ +/** + * Row, column and grid actions. + * + * The screen is notifications x channels, so these are the shapes a reader can + * batch. Two things need pinning and neither is obvious from the call site: + * that a bulk change cannot reach cells the single-cell route would refuse, and + * that `reset` is not a synonym for `enable`. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createTestDb, setupSchema } from '../db'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import * as schema from '../../../server/lib/db/schema'; + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); + +// eslint-disable-next-line import/order +import { applyBulk } from '../../../server/lib/notifications/preference-write'; +// eslint-disable-next-line import/order +import { classesFor } from '../../../server/lib/notifications/screen-model'; +// eslint-disable-next-line import/order +import { defaultEnabled } from '../../../server/lib/notifications/classes'; + +const TENANT = 't-bulk'; +const SUBJECT = { tenantId: TENANT, subjectKind: 'user' as const, subjectId: 'u1' }; + +let db: BetterSQLite3Database; +let sqlite: { close: () => void }; + +beforeEach(async () => { + const fx = createTestDb(); + db = fx.db as BetterSQLite3Database; + sqlite = fx.sqlite; + await setupSchema(fx.sqlite); +}); +afterEach(() => sqlite.close()); + +const rows = () => db.select().from(schema.notificationPreferences).all(); + +describe('bulk preference changes', () => { + it('turns off every choosable cell an AGENT has — storing only what differs', async () => { + await applyBulk(db, SUBJECT, 'agent', { action: 'disable' }); + + // One FEWER row than there are cells: `agent-invoice-paid` already + // defaults to off, so switching it off matches the default and stores + // nothing (§3.2). This is the storage rule, not an off-by-one. + const cells = classesFor('agent') + .filter((c) => !c.required) + .reduce((n, c) => n + c.channels.length, 0); + const defaultOff = classesFor('agent') + .filter((c) => !c.required && !defaultEnabled(c.id)) + .reduce((n, c) => n + c.channels.length, 0); + expect(defaultOff).toBeGreaterThan(0); + + expect(await rows()).toHaveLength(cells - defaultOff); + expect((await rows()).every((r) => r.enabled === false)).toBe(true); + }); + + it('never touches a notification that is always sent', async () => { + await applyBulk(db, SUBJECT, 'agent', { action: 'disable' }); + const ids = new Set((await rows()).map((r) => r.classId)); + expect(ids.has('agent-login-link')).toBe(false); + expect(ids.has('password-reset')).toBe(false); + }); + + it('never writes a channel the notification does not use', async () => { + // The em dash is not a control, so a column action must skip it rather + // than switch it on — otherwise a row would carry a preference behind a + // cell the screen renders as a dash. + await applyBulk(db, SUBJECT, 'agent', { action: 'disable', channel: 'sms' }); + for (const r of await rows()) { + const cls = classesFor('agent').find((c) => c.id === r.classId)!; + expect(cls.channels).toContain('sms'); + } + }); + + it('never reaches a class this audience is not addressed by', async () => { + await applyBulk(db, SUBJECT, 'client', { action: 'disable' }); + const ids = new Set((await rows()).map((r) => r.classId)); + expect(ids.has('agent-new-referral')).toBe(false); + }); + + it('limits a column action to that column', async () => { + await applyBulk(db, SUBJECT, 'client', { action: 'disable', channel: 'email' }); + expect((await rows()).every((r) => r.channel === 'email')).toBe(true); + }); + + it('limits a row action to that row', async () => { + await applyBulk(db, SUBJECT, 'agent', { action: 'disable', classId: 'agent-new-referral' }); + expect((await rows()).every((r) => r.classId === 'agent-new-referral')).toBe(true); + }); + + it('RESET is not ENABLE — it clears rows, and one class defaults to off', async () => { + // The whole reason the two are separate verbs. `agent-invoice-paid` + // defaults to OFF, so "enable everything" must store a row for it while + // "reset" must remove that row and leave the class silent. + await applyBulk(db, SUBJECT, 'agent', { action: 'enable' }); + const paid = (await rows()).filter((r) => r.classId === 'agent-invoice-paid'); + expect(paid).toHaveLength(1); + expect(paid[0].enabled).toBe(true); + + await applyBulk(db, SUBJECT, 'agent', { action: 'reset' }); + expect(await rows()).toHaveLength(0); + }); + + it('ENABLE stores nothing for classes that already default to on', async () => { + // §3.2 — a row that merely restates the default makes the table grow + // with the user base instead of with the decisions. + await applyBulk(db, SUBJECT, 'agent', { action: 'enable', classId: 'agent-new-referral' }); + expect(await rows()).toHaveLength(0); + }); + + it('resets only the cells in scope, leaving the rest of the decisions alone', async () => { + await applyBulk(db, SUBJECT, 'agent', { action: 'disable' }); + const before = (await rows()).length; + + await applyBulk(db, SUBJECT, 'agent', { action: 'reset', classId: 'agent-new-referral' }); + const after = await rows(); + expect(after.length).toBeLessThan(before); + expect(after.some((r) => r.classId === 'agent-new-referral')).toBe(false); + expect(after.some((r) => r.classId === 'agent-report-ready')).toBe(true); + }); +}); From 91b1630a1a50b83064308265d1fa4124d6c01703 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 31 Jul 2026 18:45:44 +0800 Subject: [PATCH 18/48] feat(notifications): the screen offers every channel, always MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grid no longer narrows itself to what a class declares today, and it never reads the tenant's automation rules or templates. Every notification a reader is addressed by shows all three channels, and `unavailable` (the em dash) is gone from the model entirely. A preference is a statement of INTENT. "Do not text me about bookings" is a true and useful sentence before anyone has written that text, and storing it now means the answer is honoured the moment the content and the rule are completed — rather than being unaskable until then and silently lost in between. The asymmetry is what makes this safe: the switch's meaningful direction is OFF, and OFF always works. A channel left ON that nothing sends yet is quiet, not broken. The alternative — deriving the cells from what the tenant can send today — would have made the screen change shape underneath the reader every time an operator toggled a rule or added a template body, which is the confusion this avoids. Consequences, all deliberate: - `assertChoosable` loses its channel refusal. Storing a preference for a channel nothing sends on yet is now the point, not an error. - `applyBulk` covers all three channels rather than the class's own list. - `classes.ts`'s `channels` is unchanged and still the truth about what the CODE can send; it still gates the send path. It just no longer decides what the screen offers, and `automation-classes.spec.ts` still holds it honest against the seeds. Every spec that pinned the old answer was rewritten rather than deleted, and one was inverted on purpose: "never writes a channel the notification does not use" is now "DOES write a channel the notification does not send on yet", because narrowing the write is exactly how the answer would be dropped. Verified in Chrome across all three surfaces. On the client Hub, turning the Text column off wrote 10 rows — one per choosable class, including those with no SMS form in the code today — while the corner and every row control moved to indeterminate. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RgBRZQhGELdkaWGorWwWKV --- .../NotificationPreferences.test.tsx | 25 +++++----- .../notifications/NotificationPreferences.tsx | 46 ++++++------------- app/routes/agent/settings-profile.test.tsx | 16 +++---- messages/en/components.json | 5 +- server/api/agent/notification-preferences.ts | 2 +- server/api/notification-preferences.ts | 2 +- server/api/portal/notification-preferences.ts | 2 +- server/lib/notifications/preference-write.ts | 33 +++++++------ server/lib/notifications/screen-model.ts | 29 ++++++++---- .../notifications/preference-bulk.spec.ts | 40 ++++++++-------- tests/unit/notifications/screen-model.spec.ts | 20 +++++--- 11 files changed, 117 insertions(+), 103 deletions(-) diff --git a/app/components/notifications/NotificationPreferences.test.tsx b/app/components/notifications/NotificationPreferences.test.tsx index 5fc2162ad..31b376bb1 100644 --- a/app/components/notifications/NotificationPreferences.test.tsx +++ b/app/components/notifications/NotificationPreferences.test.tsx @@ -12,8 +12,8 @@ import { render, fireEvent } from "@testing-library/react"; import { NotificationPreferences, type ChoiceRow } from "./NotificationPreferences"; const rows: ChoiceRow[] = [ - { id: "a", label: "Alpha", channels: { email: "on", sms: "unavailable", in_app: "unavailable" } }, - { id: "b", label: "Beta", channels: { email: "off", sms: "unavailable", in_app: "on" } }, + { id: "a", label: "Alpha", channels: { email: "on", sms: "on", in_app: "on" } }, + { id: "b", label: "Beta", channels: { email: "off", sms: "on", in_app: "on" } }, ]; function setup(over: Partial[0]> = {}) { @@ -37,13 +37,15 @@ const byLabel = (c: ReturnType, startsWith: string) => boxes(c).find((b) => (b.getAttribute("aria-label") ?? "").startsWith(startsWith))!; describe("bulk controls", () => { - it("renders NO control for a channel every row is unavailable on", () => { - // Text is an em dash on both rows. A checkbox there would be a control - // over nothing. + it("offers every channel on every row", () => { + // The screen reads neither the class's own channel list nor the tenant's + // templates: a preference is a statement of intent worth storing before + // the content exists. const c = setup(); - expect(boxes(c).some((b) => (b.getAttribute("aria-label") ?? "").includes("Text"))).toBe(false); - // In-app has one real cell, so it keeps its control. - expect(byLabel(c, "Turn In-app")).toBeTruthy(); + for (const ch of ["Email", "Text", "In-app"]) { + expect(byLabel(c, `Turn ${ch}`)).toBeTruthy(); + } + expect(boxes(c).filter((b) => (b.getAttribute("aria-label") ?? "").startsWith("Alpha"))).toHaveLength(3); }); it("shows a partly-on column as indeterminate, not as unchecked", () => { @@ -89,9 +91,10 @@ describe("bulk controls", () => { expect(c.queryByText(/^Saved$/)).toBeNull(); }); - it("says what the dash means, rather than leaving it to be guessed", () => { - // A reader who has to ask will guess "off", which is the wrong answer. + it("says that a choice keeps applying if we start sending a new way later", () => { + // Otherwise a reader switching off a channel nothing uses yet has no way + // to know the answer was kept rather than ignored. const c = setup(); - expect(c.getByText(/dash means/i)).toBeTruthy(); + expect(c.getByText(/keeps applying/i)).toBeTruthy(); }); }); diff --git a/app/components/notifications/NotificationPreferences.tsx b/app/components/notifications/NotificationPreferences.tsx index 47319f834..eb80c1ffe 100644 --- a/app/components/notifications/NotificationPreferences.tsx +++ b/app/components/notifications/NotificationPreferences.tsx @@ -27,7 +27,7 @@ import { m } from "~/paraglide/messages"; * implementation would drift, and only one of the three would get the next fix. */ -export type ChannelState = "on" | "off" | "unavailable"; +export type ChannelState = "on" | "off"; export type ChannelId = "email" | "sms" | "in_app"; export interface AlwaysSentItem { @@ -72,15 +72,9 @@ export interface NotificationPreferencesProps { onBulk?: (enabled: boolean, scope: { channel?: ChannelId; classId?: string }) => void; } -/** All | none | some of the cells in scope are on. `unavailable` never counts. */ +/** All | none | some of the cells in scope are on. */ type BulkState = "all" | "none" | "some"; -/** - * @returns `null` when the scope contains NO selectable cell — a column whose - * every row is an em dash, say. That must render no control at all: - * an empty checkbox there reads as "all off" and does nothing when - * clicked, which is precisely the lie the em dash exists to avoid. - */ function bulkStateOf( rows: ChoiceRow[], scope: { channel?: ChannelId; classId?: string }, @@ -90,10 +84,7 @@ function bulkStateOf( if (scope.classId && r.id !== scope.classId) continue; for (const c of CHANNELS) { if (scope.channel && c.id !== scope.channel) continue; - const st = r.channels[c.id]; - // An em dash is not a control, so it is not a vote either — a column - // whose only rows are unavailable must not read as "all off". - if (st !== "unavailable") cells.push(st); + cells.push(r.channels[c.id]); } } if (cells.length === 0) return null; @@ -140,29 +131,19 @@ function ChannelCell({ onChange: NotificationPreferencesProps["onChange"]; busy: boolean; }) { - const state = row.channels[channel]; return (
{/* The channel name repeats per cell on narrow screens, where the column header is not there to supply it. Hidden from AT on wide screens only — the checkbox keeps its own full label either way. */} {channelLabel} - {state === "unavailable" ? ( - <> - - - {m.notif_prefs_channel_unavailable({ channel: channelLabel })} - - - ) : ( - onChange(row.id, channel, e.currentTarget.checked)} - /> - )} + onChange(row.id, channel, e.currentTarget.checked)} + />
); } @@ -299,9 +280,10 @@ export function NotificationPreferences({ ))} - {/* The em dash needs saying once. A reader who has to - ask what a symbol means has been left to guess, and - the guess here ("it's off") is the wrong one. */} + {/* Every notification shows every channel, always. A + channel with nothing behind it yet is quiet, not + broken — and switching it off now is honoured the + moment something does send on it. */}

{m.notif_prefs_legend()}

)} diff --git a/app/routes/agent/settings-profile.test.tsx b/app/routes/agent/settings-profile.test.tsx index 991639cd7..e76310be3 100644 --- a/app/routes/agent/settings-profile.test.tsx +++ b/app/routes/agent/settings-profile.test.tsx @@ -88,12 +88,12 @@ const SAMPLE_SCREEN = { { id: "agent-new-referral", label: "A new referral is booked", - channels: { email: "on", sms: "unavailable", in_app: "unavailable" }, + channels: { email: "on", sms: "on", in_app: "on" }, }, { id: "agent-report-ready", label: "A report is ready to read", - channels: { email: "off", sms: "unavailable", in_app: "unavailable" }, + channels: { email: "off", sms: "on", in_app: "on" }, }, ], }; @@ -237,14 +237,14 @@ describe("AgentSettingsProfilePage rendering", () => { await findByDisplayValue("Acme Inspections"); }); - it("shows a switched-off notification as unchecked, and an off-channel as neither", async () => { + it("shows a switched-off notification as unchecked, on the channel it was switched off on", async () => { const { findAllByRole, getByText } = renderPage(); const boxes = await findAllByRole("checkbox") as HTMLInputElement[]; - // agent-new-referral email = on, agent-report-ready email = off. The two - // rows contribute one checkbox each; the sms/in_app cells are em dashes, - // which is what "unavailable" must look like — NOT an unchecked box. - const notifBoxes = boxes.filter((b) => (b.getAttribute("aria-label") ?? "").includes("—")); - expect(notifBoxes.map((b) => b.checked)).toEqual([true, false]); + // Every row offers all three channels now, so each contributes three cells. + // The fixture has agent-new-referral email = on, agent-report-ready email + // = off; the rest default to on. + const email = boxes.filter((b) => (b.getAttribute("aria-label") ?? "").endsWith("Email")); + expect(email.map((b) => b.checked)).toEqual([true, false]); expect(getByText("A new referral is booked")).toBeTruthy(); }); diff --git a/messages/en/components.json b/messages/en/components.json index fe1152b0b..0776a3599 100644 --- a/messages/en/components.json +++ b/messages/en/components.json @@ -158,10 +158,9 @@ "notif_prefs_bulk_column": "Turn {channel} on or off for every notification", "notif_prefs_bulk_row": "Turn {notification} on or off on every channel", "notif_prefs_save_failed": "Couldn't save that. Please try again.", - "notif_prefs_legend": "A dash means we don't send that notification on that channel, so there's nothing to switch.", + "notif_prefs_legend": "Every notification is listed here, on every channel. Switching one off applies straight away, and keeps applying if we start sending it a new way later.", "notif_prefs_choose_empty": "Nothing here yet. When there is something you can switch off, it will appear here.", "notif_prefs_channel_email": "Email", "notif_prefs_channel_sms": "Text", - "notif_prefs_channel_in_app": "In-app", - "notif_prefs_channel_unavailable": "Not sent by {channel}" + "notif_prefs_channel_in_app": "In-app" } diff --git a/server/api/agent/notification-preferences.ts b/server/api/agent/notification-preferences.ts index d18b9c4fb..a5d9d86de 100644 --- a/server/api/agent/notification-preferences.ts +++ b/server/api/agent/notification-preferences.ts @@ -173,7 +173,7 @@ const agentNotificationPreferenceRoutes = createApiRouter() // Refused at the edge as well as at the send boundary — the boundary is // what makes a preference true, this is what keeps the screen honest. - assertChoosable(classId, channel, 'agent'); + assertChoosable(classId, 'agent'); const db = getDrizzle(c); const companies = await listAgentCompanies(db, agentUserId); diff --git a/server/api/notification-preferences.ts b/server/api/notification-preferences.ts index d40df1210..5e5d0cb1a 100644 --- a/server/api/notification-preferences.ts +++ b/server/api/notification-preferences.ts @@ -132,7 +132,7 @@ const notificationPreferenceRoutes = createApiRouter() // Refused at the edge as well as at the send boundary. The boundary is // what makes it true; this is what makes it HONEST — a screen that // accepts the change and then ignores it is worse than one that says no. - assertChoosable(classId, channel, 'staff'); + assertChoosable(classId, 'staff'); await writeChoice(getDrizzle(c), { tenantId, subjectKind: 'user', subjectId: userId, classId, channel, enabled, diff --git a/server/api/portal/notification-preferences.ts b/server/api/portal/notification-preferences.ts index e0c923273..39e2bb1f4 100644 --- a/server/api/portal/notification-preferences.ts +++ b/server/api/portal/notification-preferences.ts @@ -158,7 +158,7 @@ const portalNotificationPreferenceRoutes = router // Refused at the edge as well as at the send boundary — the boundary is // what makes a preference true, this is what keeps the screen honest. - assertChoosable(classId, channel, 'client'); + assertChoosable(classId, 'client'); const db = getDrizzle(c); const contactIds = await contactIdsForEmail(db, tenantId, c.get('portalEmail') as string); diff --git a/server/lib/notifications/preference-write.ts b/server/lib/notifications/preference-write.ts index da736781c..0c2220ca9 100644 --- a/server/lib/notifications/preference-write.ts +++ b/server/lib/notifications/preference-write.ts @@ -17,6 +17,10 @@ import { Errors } from '../errors'; * — nobody reports mail they did not receive. */ +/** Every channel the grid offers — see `buildScreenModel` for why it is not + * narrowed to what a class declares today. */ +const ALL_CHANNELS = ['email', 'sms', 'in_app'] as const; + export interface PreferenceWrite { tenantId: string; subjectKind: 'user' | 'contact'; @@ -29,17 +33,22 @@ export interface PreferenceWrite { /** * Refuse anything the send boundary would not honour, for this reader. * + * There is no CHANNEL check, and its absence is the point: the screen offers + * every channel for every notification, because a preference is a statement of + * intent worth storing before the content to send exists (`buildScreenModel`). + * * Ordering is deliberate: unknown class first (nothing else can be checked - * without it), then required, then the channel, then the audience. Each throws - * a 400 with a sentence a reader could act on rather than a code. + * without it), then required, then the audience. Each throws a 400 with a + * sentence a reader could act on rather than a code. */ -export function assertChoosable(classId: string, channel: string, audience: Audience): void { +export function assertChoosable(classId: string, audience: Audience): void { const cls = notificationClass(classId); if (!cls) throw Errors.BadRequest('Unknown notification.'); if (!isSuppressible(classId)) throw Errors.BadRequest('This notification is always sent.'); - if (!cls.channels.includes(channel as 'email' | 'sms' | 'in_app')) { - throw Errors.BadRequest('This notification is not sent on that channel.'); - } + // NO channel check. The screen offers every channel for every notification, + // because a preference is a statement of intent that is worth storing + // before the content exists — see `buildScreenModel`. A row for a channel + // nothing sends yet is inert, and becomes effective the moment it does. // A class this reader is never addressed by cannot take effect for them, and // the row would be one they can neither see nor clear — nothing renders it. if (!cls.audience.includes(audience) || cls.recipientFacing === false) { @@ -130,12 +139,10 @@ export interface BulkChange { * OFF (`agent-invoice-paid`, whose column defaulted to false). Treating them as * synonyms would silently switch that one on. * - * The cells it touches come from `classesFor(audience)` intersected with each - * class's own channel list, so a bulk change can never reach a class this - * reader is not addressed by, a class that is always sent, or a channel the - * class never uses — the three refusals `assertChoosable` makes one at a time, - * made structural instead. A row's `unavailable` cells are skipped rather than - * switched on, which is the whole reason the em dash is not a control. + * The cells it touches come from `classesFor(audience)`, so a bulk change can + * never reach a class this reader is not addressed by or one that is always + * sent — two of the refusals `assertChoosable` makes one at a time, made + * structural instead. Every channel is in scope, matching what the grid shows. */ export async function applyBulk( // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -147,7 +154,7 @@ export async function applyBulk( const targets = classesFor(audience) .filter((c) => !c.required) .filter((c) => !change.classId || c.id === change.classId) - .flatMap((c) => c.channels + .flatMap((c) => ALL_CHANNELS .filter((ch) => !change.channel || ch === change.channel) .map((ch) => ({ cls: c, channel: ch }))); diff --git a/server/lib/notifications/screen-model.ts b/server/lib/notifications/screen-model.ts index ea5af3758..c2c650b09 100644 --- a/server/lib/notifications/screen-model.ts +++ b/server/lib/notifications/screen-model.ts @@ -15,17 +15,15 @@ import { NOTIFICATION_CLASSES, defaultEnabled, type Audience, type NotificationC * appears in all three or none. */ -/** A channel's state on a row. `unavailable` is NOT "off" — see below. */ -export type ChannelState = 'on' | 'off' | 'unavailable'; +/** A channel's state on a row. */ +export type ChannelState = 'on' | 'off'; export interface ScreenRow { id: string; label: string; /** - * Per channel. `unavailable` means the class never uses it, which §4 renders - * as `—`: showing an off-switch for a channel that does not exist is a lie - * about what exists, and a reader who flips it would be right to expect - * something to change. + * Every channel, always — see `buildScreenModel` for why the screen does + * not narrow this to what the class or the tenant can send today. */ channels: Record<'email' | 'sms' | 'in_app', ChannelState>; } @@ -46,6 +44,22 @@ export function classesFor(audience: Audience): NotificationClass[] { } /** + * EVERY CLASS SHOWS EVERY CHANNEL, and the screen reads neither the class's + * own `channels` list nor the tenant's automation rules and templates. + * + * A preference is a statement of INTENT — "do not text me about bookings" — and + * that sentence is true and worth storing before anyone has written the text. + * When the content and the rule are completed the stored answer simply takes + * effect, with no screen that changed shape underneath the reader and no + * decision silently lost in between. + * + * The asymmetry is what makes this safe: the switch's meaningful direction is + * OFF, and OFF always works. A channel left ON that nothing sends yet is not a + * broken promise, it is just quiet. + * + * `classes.ts`'s `channels` is still the truth about what the CODE can send and + * still gates the send path; it just no longer decides what the screen offers. + * * @param chosen `${classId}:${channel}` → the explicit choice this subject * stored, for the rows they actually hold. A class with NO entry * falls back to its own default, which is usually "send" but is @@ -65,8 +79,7 @@ export function buildScreenModel(audience: Audience, chosen: ReadonlyMap [ ch, - !c.channels.includes(ch) ? 'unavailable' - : (chosen.get(`${c.id}:${ch}`) ?? defaultEnabled(c.id)) ? 'on' : 'off', + (chosen.get(`${c.id}:${ch}`) ?? defaultEnabled(c.id)) ? 'on' : 'off', ])) as ScreenRow['channels'], })), }; diff --git a/tests/unit/notifications/preference-bulk.spec.ts b/tests/unit/notifications/preference-bulk.spec.ts index f3f6cb11a..ffc5c26cc 100644 --- a/tests/unit/notifications/preference-bulk.spec.ts +++ b/tests/unit/notifications/preference-bulk.spec.ts @@ -40,15 +40,13 @@ describe('bulk preference changes', () => { it('turns off every choosable cell an AGENT has — storing only what differs', async () => { await applyBulk(db, SUBJECT, 'agent', { action: 'disable' }); - // One FEWER row than there are cells: `agent-invoice-paid` already - // defaults to off, so switching it off matches the default and stores - // nothing (§3.2). This is the storage rule, not an off-by-one. - const cells = classesFor('agent') - .filter((c) => !c.required) - .reduce((n, c) => n + c.channels.length, 0); - const defaultOff = classesFor('agent') - .filter((c) => !c.required && !defaultEnabled(c.id)) - .reduce((n, c) => n + c.channels.length, 0); + // Every choosable class x every channel — the grid offers all three + // regardless of what the class declares today. Fewer rows than cells, + // because `agent-invoice-paid` already defaults to off and switching it + // off matches the default, storing nothing (§3.2). + const choosable = classesFor('agent').filter((c) => !c.required); + const cells = choosable.length * 3; + const defaultOff = choosable.filter((c) => !defaultEnabled(c.id)).length * 3; expect(defaultOff).toBeGreaterThan(0); expect(await rows()).toHaveLength(cells - defaultOff); @@ -62,15 +60,19 @@ describe('bulk preference changes', () => { expect(ids.has('password-reset')).toBe(false); }); - it('never writes a channel the notification does not use', async () => { - // The em dash is not a control, so a column action must skip it rather - // than switch it on — otherwise a row would carry a preference behind a - // cell the screen renders as a dash. + it('DOES write a channel the notification does not send on yet', async () => { + // The inverse of the rule this file first pinned, and deliberate: a + // preference is a statement of intent, so "no texts about this" is + // stored now and becomes effective the moment a text exists. Narrowing + // the write to today's channels would silently drop the answer. await applyBulk(db, SUBJECT, 'agent', { action: 'disable', channel: 'sms' }); - for (const r of await rows()) { - const cls = classesFor('agent').find((c) => c.id === r.classId)!; - expect(cls.channels).toContain('sms'); - } + const written = await rows(); + expect(written.length).toBeGreaterThan(0); + expect(written.every((r) => r.channel === 'sms')).toBe(true); + + const declaresSms = classesFor('agent') + .filter((c) => !c.required && c.channels.includes('sms')); + expect(declaresSms.length).toBeLessThan(written.length); }); it('never reaches a class this audience is not addressed by', async () => { @@ -95,8 +97,8 @@ describe('bulk preference changes', () => { // "reset" must remove that row and leave the class silent. await applyBulk(db, SUBJECT, 'agent', { action: 'enable' }); const paid = (await rows()).filter((r) => r.classId === 'agent-invoice-paid'); - expect(paid).toHaveLength(1); - expect(paid[0].enabled).toBe(true); + expect(paid).toHaveLength(3); // one per channel + expect(paid.every((r) => r.enabled === true)).toBe(true); await applyBulk(db, SUBJECT, 'agent', { action: 'reset' }); expect(await rows()).toHaveLength(0); diff --git a/tests/unit/notifications/screen-model.spec.ts b/tests/unit/notifications/screen-model.spec.ts index fb7046ca6..8697b84a4 100644 --- a/tests/unit/notifications/screen-model.spec.ts +++ b/tests/unit/notifications/screen-model.spec.ts @@ -47,14 +47,22 @@ describe('notifications screen model', () => { expect(m.youChoose.length).toBeGreaterThan(0); }); - it('marks a channel the class never uses as unavailable, not as off', () => { - // §4: `—` is distinct from "off". A review request has no in-app form; - // an off-switch for it would be a lie about what exists, and a reader - // who turned it on would be right to expect something. + it('offers EVERY channel on every row, whatever the class declares today', () => { + // A review request has no in-app form in the code right now. The screen + // still offers the switch, because the reader's "not in-app, thanks" is + // a true sentence today and the answer must not be lost between now and + // whenever someone writes that form. The switch's meaningful direction + // is OFF, and OFF always works. const row = buildScreenModel('client', noChoices).youChoose.find((r) => r.id === 'review-request')!; expect(row.channels.email).toBe('on'); - expect(row.channels.in_app).toBe('unavailable'); - expect(row.channels.sms).toBe('unavailable'); + expect(row.channels.in_app).toBe('on'); + expect(row.channels.sms).toBe('on'); + }); + + it('honours a choice made on a channel the class does not send on yet', () => { + const off = buildScreenModel('client', new Map([['review-request:sms', false]])) + .youChoose.find((r) => r.id === 'review-request')!; + expect(off.channels.sms).toBe('off'); }); it('reads absence as the CLASS default, which is usually but not always on', () => { From 9ff58128d08a9be66c6f0e40308dbe5e41e2c564 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 31 Jul 2026 19:41:07 +0800 Subject: [PATCH 19/48] =?UTF-8?q?feat(notifications):=20SMS=20consent=20bl?= =?UTF-8?q?ock,=20and=20spec=20=C2=A74.2=20for=20what=20a=20channel=20swit?= =?UTF-8?q?ch=20means?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V4's first half. The grid answers *which notifications*; this answers *this channel at all*, and the two are different questions the send gate already reads in that order — consent first, then preference. Someone can consent to texts and still not want booking confirmations. Spec §4 was rewritten where it had gone stale or was never written: - the mock still showed em dashes, which the previous commit retired - "the SMS block shows the LEDGER, not a switch" predated per-notification SMS preferences; §4.2 now states how the ledger and the Text column coexist - "deployment-aware rendering" was a roadmap phrase with no definition. It has one now, and the answer is that deployment mode changes NOTHING — hiding the block when a tenant has no SMS provider would make the screen read tenant configuration, which §4 choice 3 exists to avoid. THE TWO CHANNELS ARE NOT SYMMETRICAL, and §4.2 says so in a table rather than letting the code imply otherwise. SMS has a legal consent artifact (`sms_consent_log`); email has only deliverability suppression, which is a different fact. So switching SMS off writes a `revoked` row AND cascades the Text column; switching email off cascades only — and email's "off" can never mean "no email", because required classes still send. That last point is said out loud in the UI rather than left to be discovered. There is deliberately NO "turn back on" control. Granting consent means recording a disclosure version, capture method, ip and user agent — evidence only `/sms-optin/:token` can honestly produce, so the block offers Stop and sends the reader out to grant. Revocation delegates to `SmsConsentService` rather than inserting directly, because that is what stamps the current disclosure version; a hand-rolled insert would drift from the version the opt-in page and the STOP webhook both use. Who sees the block, and why staff do not: consent attaches to a `contacts` row and a staff member is a `users` row, and no user-facing class is both staff-addressed and SMS. There is nothing to revoke. Inventing a staff consent row so the screen looks uniform would be a control over nothing — the same mistake as an off-switch on a channel that does not exist. An agent's revocation is recorded AS an agent's, because the ledger column exists to say which basis the person was reachable under. Chrome caught one thing the tests could not: the ledger date rendered as 2026年6月13日 inside an otherwise-English page, because `toLocaleDateString(undefined, …)` reads navigator.language. The obvious fix, `useDisplayLocale()`, needs route loader data the token-authenticated client portal does not have — so the locale is a prop, which works on all three surfaces and keeps the component renderable on its own. Still open in V4: the not-signed-in landing page and the legal-document links (§4.1). Legal copy changes need a version bump plus `terms:publish`, which is outward-facing and stays a human decision. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RgBRZQhGELdkaWGorWwWKV --- .../notifications/SmsConsentBlock.test.tsx | 65 ++++++++ .../notifications/SmsConsentBlock.tsx | 104 +++++++++++++ app/components/portal/hub/HubSectionSlot.tsx | 1 + .../portal/hub/PortalNotificationSection.tsx | 16 +- app/lib/portal-notification-preferences.ts | 15 +- app/routes/agent/settings-profile.tsx | 17 +- messages/en/components.json | 14 ++ server/api/agent/notification-preferences.ts | 22 +++ server/api/notification-preferences.ts | 11 +- server/api/portal/notification-preferences.ts | 24 ++- server/lib/notifications/channel-consent.ts | 145 ++++++++++++++++++ 11 files changed, 425 insertions(+), 9 deletions(-) create mode 100644 app/components/notifications/SmsConsentBlock.test.tsx create mode 100644 app/components/notifications/SmsConsentBlock.tsx create mode 100644 server/lib/notifications/channel-consent.ts diff --git a/app/components/notifications/SmsConsentBlock.test.tsx b/app/components/notifications/SmsConsentBlock.test.tsx new file mode 100644 index 000000000..31e8dcbe1 --- /dev/null +++ b/app/components/notifications/SmsConsentBlock.test.tsx @@ -0,0 +1,65 @@ +/** + * The consent block, and the one control it deliberately does NOT have. + * + * Granting SMS consent means recording a disclosure version, a capture method, + * an ip and a user agent. Only the opt-in page can honestly produce that, so a + * switch here would be manufacturing evidence — the block offers Stop, and + * sends the reader out to grant. + */ +import { describe, it, expect, vi } from "vitest"; +import { render } from "@testing-library/react"; +import { SmsConsentBlock, type SmsConsent } from "./SmsConsentBlock"; + +const base: SmsConsent = { phone: "+1 555 000 1111", state: "granted", at: "2026-06-12T00:00:00.000Z", capturedVia: "booking_form" }; + +function setup(consent: Partial = {}, manageHref?: string) { + const onStop = vi.fn(); + const utils = render( + , + ); + return { ...utils, onStop }; +} + +describe("SMS consent block", () => { + it("formats the ledger date in the APP's locale, not the browser's", () => { + // `toLocaleDateString(undefined, …)` reads navigator.language and printed a + // Chinese date inside an English page. Caught in Chrome, not here. + const c = setup(); + expect(c.getByText(/Jun 1[23], 2026/)).toBeTruthy(); + }); + + it("shows the number, and when and how consent was captured", () => { + // The ledger IS the compliance evidence. Showing it costs nothing and + // turns a record we must keep anyway into something the reader benefits from. + const c = setup(); + expect(c.getByText(/\+1 555 000 1111/)).toBeTruthy(); + expect(c.getByText(/booking form/i)).toBeTruthy(); + }); + + it("offers Stop while texts are on", () => { + const c = setup(); + c.getByText(/Stop texts/i).click(); + expect(c.onStop).toHaveBeenCalled(); + }); + + it("offers NO way to switch consent back on, only a way out to the opt-in page", () => { + const c = setup({ state: "revoked" }, "/sms-optin/abc"); + expect(c.queryByText(/Stop texts/i)).toBeNull(); + expect(c.getByText(/Manage texts/i)).toBeTruthy(); + }); + + it("says an agent is reachable under the relationship, without claiming a grant", () => { + // Implied consent has no grant date to show. Printing one would be + // inventing evidence; saying nothing would look like a bug. + const c = setup({ state: "implied", at: null, capturedVia: null }); + expect(c.getByText(/already doing together/i)).toBeTruthy(); + expect(c.getByText(/Stop texts/i)).toBeTruthy(); + }); + + it("distinguishes “you stopped” from “you never asked”", () => { + // Both are OFF, but only one is a decision the reader made. Collapsing them + // would tell someone they opted out of something they never saw. + expect(setup({ state: "revoked" }).getByText(/You stopped them/i)).toBeTruthy(); + expect(setup({ state: "none", at: null, capturedVia: null }).getByText(/no record that you asked/i)).toBeTruthy(); + }); +}); diff --git a/app/components/notifications/SmsConsentBlock.tsx b/app/components/notifications/SmsConsentBlock.tsx new file mode 100644 index 000000000..ca3b31725 --- /dev/null +++ b/app/components/notifications/SmsConsentBlock.tsx @@ -0,0 +1,104 @@ +import { Button } from "@core/shared-ui"; +import { formatDate } from "~/lib/format"; +import { m } from "~/paraglide/messages"; + +/** + * Consent for the text channel — a different question from the Text column + * beside it (spec §4.2). + * + * Consent answers *may we text you at all* and is the legal record; the grid + * answers *which of those texts do you want*. Someone can consent to texts and + * still not want booking confirmations, and the send gate reads them in that + * order, so a screen showing both is the screen agreeing with the code. + * + * THERE IS NO "TURN BACK ON" BUTTON, and that is deliberate. Granting consent + * means recording a disclosure version, a capture method, an ip and a user + * agent — evidence only the opt-in page can honestly produce. A switch here + * would be manufacturing it. Stopping needs no such ceremony, which is why it + * IS a button. + */ + +export type SmsConsentState = "granted" | "implied" | "revoked" | "none"; + +export interface SmsConsent { + phone: string | null; + state: SmsConsentState; + at: string | null; + capturedVia: "booking_form" | "optin_link" | "admin" | null; +} + +const SOURCE = { + booking_form: () => m.notif_prefs_source_booking_form(), + optin_link: () => m.notif_prefs_source_optin_link(), + admin: () => m.notif_prefs_source_admin(), +}; + +export function SmsConsentBlock({ + consent, manageHref, onStop, busy = false, locale = "en-US", +}: { + consent: SmsConsent; + /** The opt-in page — where consent can be granted with its disclosure. */ + manageHref?: string | undefined; + onStop: () => void; + busy?: boolean; + /** + * The APP's locale, passed in rather than read from a hook. + * + * `toLocaleDateString(undefined, …)` reads navigator.language and rendered + * a Chinese date inside an otherwise-English page (caught in Chrome). The + * obvious fix — `useDisplayLocale()` — needs route loader data, which the + * token-authenticated client portal does not have. A prop works on all + * three surfaces and keeps this component renderable on its own. + */ + locale?: string; +}) { + const day = (iso: string | null) => (iso ? formatDate(iso, { locale }) : ""); + const on = consent.state === "granted" || consent.state === "implied"; + + return ( +
+

+ {m.notif_prefs_sms_heading()} +

+ +

+ {consent.state === "revoked" ? m.notif_prefs_sms_revoked({ date: day(consent.at) }) + : consent.state === "none" ? m.notif_prefs_sms_none() + : consent.phone ? m.notif_prefs_sms_on({ phone: consent.phone }) + : m.notif_prefs_sms_on_no_phone()} +

+ + {/* The ledger line. It is the same fact a carrier audit would ask + for, which is why showing it to the reader costs nothing and + turns compliance evidence into something they benefit from. */} + {consent.state === "granted" && consent.at && consent.capturedVia && ( +

+ {m.notif_prefs_sms_captured({ + date: day(consent.at), + source: SOURCE[consent.capturedVia](), + })} +

+ )} + {consent.state === "implied" && ( +

{m.notif_prefs_sms_implied()}

+ )} + +
+ {on && ( + + )} + {manageHref && ( + + {m.notif_prefs_sms_manage()} → + + )} + {m.notif_prefs_sms_stop_hint()} +
+
+ ); +} diff --git a/app/components/portal/hub/HubSectionSlot.tsx b/app/components/portal/hub/HubSectionSlot.tsx index 27111a291..a9db26cf5 100644 --- a/app/components/portal/hub/HubSectionSlot.tsx +++ b/app/components/portal/hub/HubSectionSlot.tsx @@ -88,6 +88,7 @@ export function HubSectionSlot({ alwaysSent={notifications.alwaysSent} youChoose={notifications.youChoose} error={notifications.error} + smsConsent={notifications.smsConsent} /> ); } else if (section === "documents") { diff --git a/app/components/portal/hub/PortalNotificationSection.tsx b/app/components/portal/hub/PortalNotificationSection.tsx index 5069b5fa9..07e05f49e 100644 --- a/app/components/portal/hub/PortalNotificationSection.tsx +++ b/app/components/portal/hub/PortalNotificationSection.tsx @@ -5,6 +5,7 @@ import { type ChannelId, type ChoiceRow, } from "~/components/notifications/NotificationPreferences"; +import { SmsConsentBlock, type SmsConsent } from "~/components/notifications/SmsConsentBlock"; import { useNotificationSaveToast } from "~/hooks/useNotificationSaveToast"; import { m } from "~/paraglide/messages"; @@ -20,11 +21,14 @@ import { m } from "~/paraglide/messages"; * route's job rather than this one's. */ export function PortalNotificationSection({ - alwaysSent, youChoose, error, + alwaysSent, youChoose, error, smsConsent, manageTextsHref, }: { alwaysSent: AlwaysSentItem[]; youChoose: ChoiceRow[]; error: string | null; + /** Null when this reader has no SMS identity — the block does not render. */ + smsConsent: SmsConsent | null; + manageTextsHref?: string | undefined; }) { const fetcher = useFetcher<{ ok?: boolean; intent?: string; error?: string }>(); const result = fetcher.data?.intent === "notification-preference" || fetcher.data?.intent === "notification-bulk" @@ -72,6 +76,16 @@ export function PortalNotificationSection({ status={status} onBulk={bulk} /> + {smsConsent && ( + // Stopping texts is BOTH a consent act and a cascade over the Text + // column — one request, so the two can never disagree. + bulk(false, { channel: "sms" })} + busy={fetcher.state !== "idle"} + /> + )} ); } diff --git a/app/lib/portal-notification-preferences.ts b/app/lib/portal-notification-preferences.ts index d71b80136..0db44a5ec 100644 --- a/app/lib/portal-notification-preferences.ts +++ b/app/lib/portal-notification-preferences.ts @@ -2,6 +2,7 @@ import { createApi } from "~/lib/api-client.server"; import { m } from "~/paraglide/messages"; import type { LoadContext } from "~/lib/load-context"; import type { AlwaysSentItem, ChoiceRow } from "~/components/notifications/NotificationPreferences"; +import type { SmsConsent } from "~/components/notifications/SmsConsentBlock"; /** * The client Hub's notification-settings seam (spec §4.1) — its own module @@ -14,6 +15,8 @@ export interface NotificationsLoaderResult { alwaysSent: AlwaysSentItem[]; youChoose: ChoiceRow[]; error: string | null; + /** Null when this reader has no SMS identity to consent with (§4.2). */ + smsConsent: SmsConsent | null; } /** @@ -36,13 +39,15 @@ export async function loadNotificationsSection( { headers: { Cookie: cookieForApi } }, ); if (!res.ok) { - return { alwaysSent: [], youChoose: [], error: m.helper_section_service_unavailable() }; + return { alwaysSent: [], youChoose: [], smsConsent: null, error: m.helper_section_service_unavailable() }; } - const body = (await res.json()) as { data?: { alwaysSent: AlwaysSentItem[]; youChoose: ChoiceRow[] } }; - const d = body.data ?? { alwaysSent: [], youChoose: [] }; - return { alwaysSent: d.alwaysSent, youChoose: d.youChoose, error: null }; + const body = (await res.json()) as { + data?: { alwaysSent: AlwaysSentItem[]; youChoose: ChoiceRow[]; smsConsent: SmsConsent | null }; + }; + const d = body.data ?? { alwaysSent: [], youChoose: [], smsConsent: null }; + return { alwaysSent: d.alwaysSent, youChoose: d.youChoose, smsConsent: d.smsConsent ?? null, error: null }; } catch { - return { alwaysSent: [], youChoose: [], error: m.helper_section_service_unavailable() }; + return { alwaysSent: [], youChoose: [], smsConsent: null, error: m.helper_section_service_unavailable() }; } } diff --git a/app/routes/agent/settings-profile.tsx b/app/routes/agent/settings-profile.tsx index 8a6d1c53a..9e6868fb0 100644 --- a/app/routes/agent/settings-profile.tsx +++ b/app/routes/agent/settings-profile.tsx @@ -13,6 +13,8 @@ import { type ChoiceRow, } from "~/components/notifications/NotificationPreferences"; import { TIMEZONE_SELECT_OPTIONS } from "~/lib/timezones"; +import { SmsConsentBlock, type SmsConsent } from "~/components/notifications/SmsConsentBlock"; +import { useDisplayLocale } from "~/hooks/useSessionContext"; import { useNotificationSaveToast } from "~/hooks/useNotificationSaveToast"; import { m } from "~/paraglide/messages"; @@ -41,10 +43,12 @@ interface NotificationScreen { /** The read failed. NOT the same as "no company has added you yet" — one is * a broken page, the other is an invitation to wait. */ error: string | null; + /** Consent is per COMPANY too — it attaches to that company's contact row. */ + smsConsent: SmsConsent | null; } const FAILED_SCREEN = (): NotificationScreen => ({ - companies: [], selected: null, alwaysSent: [], youChoose: [], + companies: [], selected: null, alwaysSent: [], youChoose: [], smsConsent: null, error: m.settings_notifications_unavailable(), }); @@ -160,6 +164,7 @@ export default function AgentSettingsProfilePage() { // on screen when the reader looks up from the switch they just moved. const notifyStatus = notifyFetcher.state !== "idle" ? "saving" as const : "idle" as const; const navigate = useNavigate(); + const locale = useDisplayLocale(); const tzFetcher = useFetcher(); const tzResult = tzFetcher.data?.intent === "save-timezone" ? tzFetcher.data : null; @@ -299,6 +304,16 @@ export default function AgentSettingsProfilePage() { onBulk={bulkNotification} /> + {notifications.smsConsent && ( +
+ bulkNotification(false, { channel: "sms" })} + busy={notifyFetcher.state !== "idle"} + /> +
+ )} )} diff --git a/messages/en/components.json b/messages/en/components.json index 0776a3599..a5c86b830 100644 --- a/messages/en/components.json +++ b/messages/en/components.json @@ -157,6 +157,20 @@ "notif_prefs_bulk_all_short": "All", "notif_prefs_bulk_column": "Turn {channel} on or off for every notification", "notif_prefs_bulk_row": "Turn {notification} on or off on every channel", + "notif_prefs_sms_heading": "Text messages", + "notif_prefs_sms_on": "Texts to {phone} are ON.", + "notif_prefs_sms_on_no_phone": "Texts are ON.", + "notif_prefs_sms_captured": "You turned them on {date} from {source}.", + "notif_prefs_sms_implied": "We may text you about work we are already doing together. You can stop at any time.", + "notif_prefs_sms_revoked": "Texts are OFF. You stopped them {date}.", + "notif_prefs_sms_none": "Texts are OFF. We have no record that you asked for them.", + "notif_prefs_sms_stop_hint": "To stop, reply STOP to any message.", + "notif_prefs_sms_stop": "Stop texts", + "notif_prefs_sms_manage": "Manage texts", + "notif_prefs_source_booking_form": "a booking form", + "notif_prefs_source_optin_link": "an opt-in link", + "notif_prefs_source_admin": "your inspector", + "notif_prefs_email_off_note": "Switching email off stops the optional ones only. We still email you the things you cannot switch off above.", "notif_prefs_save_failed": "Couldn't save that. Please try again.", "notif_prefs_legend": "Every notification is listed here, on every channel. Switching one off applies straight away, and keeps applying if we start sending it a new way later.", "notif_prefs_choose_empty": "Nothing here yet. When there is something you can switch off, it will appear here.", diff --git a/server/api/agent/notification-preferences.ts b/server/api/agent/notification-preferences.ts index a5d9d86de..e3385dbee 100644 --- a/server/api/agent/notification-preferences.ts +++ b/server/api/agent/notification-preferences.ts @@ -6,6 +6,8 @@ import { getDrizzle } from '../../lib/route-helpers'; import { buildScreenModel } from '../../lib/notifications/screen-model'; import { applyBulk, assertChoosable, readChoices, writeChoice } from '../../lib/notifications/preference-write'; import { listAgentCompanies } from '../../services/agent/companies'; +import { readSmsConsent, revokeChannel } from '../../lib/notifications/channel-consent'; +import { SmsConsentService } from '../../services/sms-consent.service'; import { Errors } from '../../lib/errors'; /** @@ -36,6 +38,14 @@ const CompanySchema = z.object({ name: z.string().describe('Company name as the agent knows it.'), }); +const SmsConsentSchema = z.object({ + phone: z.string().nullable(), + state: z.enum(['granted', 'implied', 'revoked', 'none']), + at: z.string().nullable(), + capturedVia: z.enum(['booking_form', 'optin_link', 'admin']).nullable(), + contactIds: z.array(z.string()), +}).nullable().describe('Null when this reader has no SMS identity to consent with.'); + const ScreenResponseSchema = z.object({ success: z.literal(true), data: z.object({ @@ -49,6 +59,7 @@ const ScreenResponseSchema = z.object({ label: z.string(), channels: z.object({ email: z.string(), sms: z.string(), in_app: z.string() }), })), + smsConsent: SmsConsentSchema, }), }).openapi('AgentNotificationPreferencesScreen'); @@ -163,6 +174,11 @@ const agentNotificationPreferenceRoutes = createApiRouter() companies: companies.map((x) => ({ id: x.tenantId, name: x.name })), selected: selected?.tenantId ?? null, ...buildScreenModel('agent', chosen), + // Consent is per COMPANY, like everything else on this screen: + // it attaches to the contact row that company holds. + smsConsent: selected + ? await readSmsConsent(db, selected.tenantId, 'agent', [selected.contactId]) + : null, }, }, 200); }) @@ -212,6 +228,12 @@ const agentNotificationPreferenceRoutes = createApiRouter() 'agent', { action, ...(channel ? { channel } : {}), ...(classId ? { classId } : {}) }, ); + // A whole-channel stop is also a consent act on SMS (§4.2), and an + // agent's revocation is recorded AS an agent's. + if (action === 'disable' && channel === 'sms' && !classId) { + const block = await readSmsConsent(db, t.tenantId, 'agent', [t.contactId]); + await revokeChannel(new SmsConsentService(c.env.DB), t.tenantId, 'sms', block, 'agent'); + } } return c.json({ success: true as const, applied: targets.length }, 200); }); diff --git a/server/api/notification-preferences.ts b/server/api/notification-preferences.ts index 5e5d0cb1a..c524e5a40 100644 --- a/server/api/notification-preferences.ts +++ b/server/api/notification-preferences.ts @@ -39,6 +39,8 @@ const ScreenResponseSchema = z.object({ email: z.string(), sms: z.string(), in_app: z.string(), }), })), + /** Always null for staff — see the GET handler. */ + smsConsent: z.null(), }), }).openapi('NotificationPreferencesScreen'); @@ -122,7 +124,14 @@ const notificationPreferenceRoutes = createApiRouter() // small — a row that restates the default would make the table grow // with the user base instead of with the decisions (§3.2). const chosen = await readChoices(db, tenantId, 'user', userId); - return c.json({ success: true as const, data: buildScreenModel('staff', chosen) }, 200); + // No SMS consent block for staff: consent attaches to a `contacts` row + // and a staff member is a `users` row, and no user-facing class is both + // staff-addressed and SMS. Rendering an empty block would be a control + // over nothing (§4.2). + return c.json({ + success: true as const, + data: { ...buildScreenModel('staff', chosen), smsConsent: null }, + }, 200); }) .openapi(saveRoute, async (c) => { const tenantId = c.get('tenantId') as string; diff --git a/server/api/portal/notification-preferences.ts b/server/api/portal/notification-preferences.ts index 39e2bb1f4..88c6da126 100644 --- a/server/api/portal/notification-preferences.ts +++ b/server/api/portal/notification-preferences.ts @@ -8,6 +8,8 @@ import type { HonoConfig } from '../../types/hono'; import { buildScreenModel } from '../../lib/notifications/screen-model'; import { applyBulk, assertChoosable, readChoices, writeChoice } from '../../lib/notifications/preference-write'; import { contactIdsForEmail } from '../../services/notice-inbox'; +import { readSmsConsent, revokeChannel } from '../../lib/notifications/channel-consent'; +import { SmsConsentService } from '../../services/sms-consent.service'; import { Errors } from '../../lib/errors'; /** @@ -31,6 +33,14 @@ const TenantParam = z.object({ tenant: z.string().describe('Tenant slug (resolves the tenant from the URL path).'), }); +const SmsConsentSchema = z.object({ + phone: z.string().nullable(), + state: z.enum(['granted', 'implied', 'revoked', 'none']), + at: z.string().nullable(), + capturedVia: z.enum(['booking_form', 'optin_link', 'admin']).nullable(), + contactIds: z.array(z.string()), +}).nullable().describe('Null when this reader has no SMS identity to consent with.'); + const ScreenResponseSchema = z.object({ success: z.literal(true), data: z.object({ @@ -42,6 +52,7 @@ const ScreenResponseSchema = z.object({ label: z.string(), channels: z.object({ email: z.string(), sms: z.string(), in_app: z.string() }), })), + smsConsent: SmsConsentSchema, }), }).openapi('PortalNotificationPreferencesScreen'); @@ -51,6 +62,7 @@ const SaveSchema = z.object({ enabled: z.boolean().describe('True to receive it again; false to switch it off.'), }); + const BulkSchema = z.object({ action: z.enum(['enable', 'disable', 'reset']) .describe('enable/disable every cell in scope; reset clears them back to defaults.'), @@ -149,7 +161,11 @@ const portalNotificationPreferenceRoutes = router if (!chosen.has(key) || enabled === false) chosen.set(key, enabled); } } - return c.json({ success: true as const, data: buildScreenModel('client', chosen) }, 200); + const smsConsent = await readSmsConsent(db, tenantId, 'client', contactIds); + return c.json({ + success: true as const, + data: { ...buildScreenModel('client', chosen), smsConsent }, + }, 200); }) .openapi(saveRoute, async (c) => { const tenantId = resolveTenantId(c); @@ -185,6 +201,12 @@ const portalNotificationPreferenceRoutes = router for (const subjectId of contactIds) { await applyBulk(db, { tenantId, subjectKind: 'contact', subjectId }, 'client', change); } + // Switching a whole channel off is also a CONSENT act on SMS, and the + // ledger has to carry it wherever the reader stopped from (§4.2). + if (change.action === 'disable' && change.channel === 'sms' && !change.classId) { + const block = await readSmsConsent(db, tenantId, 'client', contactIds); + await revokeChannel(new SmsConsentService(c.env.DB), tenantId, 'sms', block, 'client'); + } return c.json({ success: true as const }, 200); }); diff --git a/server/lib/notifications/channel-consent.ts b/server/lib/notifications/channel-consent.ts new file mode 100644 index 000000000..b30e92f89 --- /dev/null +++ b/server/lib/notifications/channel-consent.ts @@ -0,0 +1,145 @@ +import { and, desc, eq, inArray } from 'drizzle-orm'; +import { contacts, smsConsentLog } from '../db/schema'; +import type { Audience } from './classes'; + +/** + * The SMS consent block on the notifications screen (spec §4.2). + * + * This is a DIFFERENT question from the grid beside it. Consent answers "may we + * text you at all" and is the legal record; a preference answers "which of + * those texts do you want". Someone can consent to texts and still not want + * booking confirmations, and the send gate already reads them in that order — + * consent first, then preference — so a screen showing both is the screen + * agreeing with the code. + */ + +export interface SmsConsentBlock { + /** The number consent attaches to, for the reader to recognise. */ + phone: string | null; + /** + * `granted` — an express grant is on file (a consumer). + * `implied` — a business counterparty under an existing relationship: no + * grant to show, but STOP binds exactly the same. + * `revoked` — they stopped, by STOP or from this screen. + * `none` — a consumer with nothing on file; we may not text them. + */ + state: 'granted' | 'implied' | 'revoked' | 'none'; + /** When the state above was recorded. Null for `implied`. */ + at: string | null; + /** How it was captured — booking form, opt-in link, or by an admin. */ + capturedVia: 'booking_form' | 'optin_link' | 'admin' | null; + /** The contact rows this reader is, so a Stop knows what to write. */ + contactIds: string[]; +} + +/** + * Read the SMS consent block for one reader. + * + * @returns `null` when the block must NOT render — which today is every staff + * reader. Consent attaches to a `contacts` row and a staff member is a + * `users` row; there is also no user-facing notification class that is + * both staff-addressed and SMS, so there is nothing to revoke. + * Inventing a staff consent row to make the screen look uniform would + * be a control over nothing, which is worse than no control (§4.2). + */ +export async function readSmsConsent( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + db: any, + tenantId: string, + audience: Audience, + contactIds: string[], +): Promise { + if (contactIds.length === 0) return null; + + const rows = await db.select({ phone: contacts.phone }) + .from(contacts) + .where(and(eq(contacts.tenantId, tenantId), inArray(contacts.id, contactIds))) + .all(); + const phone = rows.map((r: { phone: string | null }) => r.phone).find(Boolean) ?? null; + + // The LATEST row across every identity this reader holds. Revocation binds + // regardless of which contact row carried the original grant — the same + // rule the send gate applies, and the same rule the inbound STOP webhook + // records against. + const latest = await db.select({ + action: smsConsentLog.action, + createdAt: smsConsentLog.createdAt, + capturedVia: smsConsentLog.capturedVia, + }).from(smsConsentLog) + .where(and( + eq(smsConsentLog.tenantId, tenantId), + inArray(smsConsentLog.contactId, contactIds), + )) + .orderBy(desc(smsConsentLog.createdAt)).limit(1).get(); + + if (latest?.action === 'revoked') { + return { + phone, state: 'revoked', + at: toIso(latest.createdAt), capturedVia: latest.capturedVia ?? null, contactIds, + }; + } + if (latest?.action === 'granted') { + return { + phone, state: 'granted', + at: toIso(latest.createdAt), capturedVia: latest.capturedVia ?? null, contactIds, + }; + } + + // Nothing on file. What that MEANS depends on who is asking: a business + // counterparty is reachable under an existing relationship, a consumer is + // not reachable at all until they say so. + return { + phone, state: audience === 'agent' ? 'implied' : 'none', + at: null, capturedVia: null, contactIds, + }; +} + +function toIso(v: unknown): string | null { + if (v instanceof Date) return v.toISOString(); + if (typeof v === 'number') return new Date(v).toISOString(); + return null; +} + +/** + * Record that this reader stopped a channel. + * + * SMS ONLY writes anything here, because the two channels are not symmetrical + * (§4.2): `sms_consent_log` is a legal record and email has no equivalent — + * only deliverability suppression, which is a different fact. Email's "off" is + * the preference cascade alone, and the caller does that part for both. + * + * Delegates to `SmsConsentService.record` rather than inserting directly: that + * is what stamps the CURRENT disclosure version, and a hand-rolled insert would + * drift from the version the opt-in page and the STOP webhook both use — three + * writers disagreeing about what the recipient was shown. + * + * There is deliberately no "switch back on". Granting consent means recording a + * disclosure version, a capture method, an ip and a user agent — evidence only + * the opt-in page can honestly produce. A screen that flipped it inline would + * be manufacturing that evidence, so the reader is sent to `/sms-optin/:token`. + */ +export interface ConsentRecorder { + record( + tenantId: string, contactId: string, action: 'granted' | 'revoked', + capturedVia: 'booking_form' | 'optin_link' | 'admin', + meta: { ip?: string | undefined; userAgent?: string | undefined; recipientType?: 'client' | 'agent' | 'other' }, + ): Promise; +} + +export async function revokeChannel( + recorder: ConsentRecorder, + tenantId: string, + channel: 'email' | 'sms', + block: SmsConsentBlock | null, + audience: Audience, +): Promise { + if (channel !== 'sms' || !block) return; + // An agent's revocation is recorded AS an agent's. The column exists so the + // ledger says which basis the person was reachable under, and stamping + // everyone 'client' would make the evidence wrong in the one direction that + // matters to a carrier audit. + const recipientType = audience === 'agent' ? 'agent' as const : 'client' as const; + for (const contactId of block.contactIds) { + await recorder.record(tenantId, contactId, 'revoked', 'optin_link', { recipientType }); + } +} From be1c4d517cf4d59e7c3e6bb352226580f419eeb7 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 31 Jul 2026 20:49:17 +0800 Subject: [PATCH 20/48] =?UTF-8?q?feat(notifications):=20consent=20is=20the?= =?UTF-8?q?=20gate=20=E2=80=94=20inline=20grant,=20and=20a=20locked=20Text?= =?UTF-8?q?=20column?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consent block was a dead end after a stop: no Stop button (correct, already stopped) and no way back (not). Caught by looking at it, not by a test. It now sits ABOVE the grid, because consent is the gate and the grid is what happens behind it, and it can turn the channel back on. Turning it back on is an inline grant, which is only honest under one condition: the disclosure has to be ON SCREEN and its VERSION has to travel with the acknowledgement. Both hold — the text renders in the block, the version comes back with the click, and the route refuses a version that is not the current one, because a stale version means the reader agreed to text they are no longer shown. `captured_via` gains `settings_page`; the enums are type-layer only in drizzle (the DDL is plain text), so widening cost no migration. A revoked consent now LOCKS the Text column rather than merely unchecking it. No text can arrive whatever a row says, so leaving the switches live would let someone tick "yes, text me about bookings" while consent says we may not text them at all — a screen disagreeing with the send gate. The column's bulk control disappears with it, for the same reason an all-unavailable column had none. Two things only the browser could have found: - the ledger recorded NULL ip and user agent. The BFF calls the API in-process over the `API_WORKER` binding, so `cf-connecting-ip` and `user-agent` never reach the handler on their own. They are forwarded explicitly now — those two fields are what make a consent row defensible in a carrier audit, and nothing in the types or the tests would have said a word. - absent headers are OMITTED rather than sent empty. An empty string is stored as one, and a row claiming "we recorded an ip and it was blank" is worse evidence than one that plainly has none. Verified end to end in the browser: stop → column locks and the block offers a way back → acknowledge → grant lands as `granted / settings_page / version 1` with a real user agent. Still open in V4: staff SMS (needs `subject_kind`/`subject_id` on the consent ledger — a reversal of the 2026-07-30 ISV decision, so that spec and docs/sms-compliance.md change with it), the not-signed-in landing page, and the legal-document links. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RgBRZQhGELdkaWGorWwWKV --- .../NotificationPreferences.test.tsx | 20 ++++++ .../notifications/NotificationPreferences.tsx | 20 +++++- .../notifications/SmsConsentBlock.test.tsx | 43 ++++++++++-- .../notifications/SmsConsentBlock.tsx | 49 +++++++++++++- .../portal/hub/PortalNotificationSection.tsx | 37 +++++++---- app/lib/portal-notification-preferences.ts | 41 ++++++++++++ app/routes/agent/settings-profile.tsx | 34 +++++++--- app/routes/public/portal-inspection.tsx | 6 ++ messages/en/components.json | 5 ++ scripts/file-size-baseline.json | 2 +- server/api/agent/notification-preferences.ts | 5 +- server/api/portal/notification-preferences.ts | 66 ++++++++++++++++++- server/lib/db/schema/compliance.ts | 5 +- server/lib/mcp/openapi-snapshot.json | 36 ++++++++++ server/lib/notifications/channel-consent.ts | 38 +++++++++-- 15 files changed, 363 insertions(+), 44 deletions(-) diff --git a/app/components/notifications/NotificationPreferences.test.tsx b/app/components/notifications/NotificationPreferences.test.tsx index 31b376bb1..4b9ab0f96 100644 --- a/app/components/notifications/NotificationPreferences.test.tsx +++ b/app/components/notifications/NotificationPreferences.test.tsx @@ -77,6 +77,26 @@ describe("bulk controls", () => { expect(c.onBulk).toHaveBeenCalledWith(true, {}); }); + it("DISABLES a locked channel rather than just showing it off", () => { + // A revoked SMS consent makes every per-notification Text choice moot: no + // text can arrive whatever the row says. Leaving the column live would let + // someone tick "yes, text me" while consent says we may not — a screen + // disagreeing with the send gate. + const c = setup({ lockedChannels: { sms: "Text is switched off above." } }); + const sms = boxes(c).filter((b) => (b.getAttribute("aria-label") ?? "").endsWith("Text")); + expect(sms.length).toBeGreaterThan(0); + expect(sms.every((b) => b.disabled)).toBe(true); + // ...and its bulk control goes with it: a select-all over cells that + // cannot change is the empty-column lie again. + expect(boxes(c).some((b) => (b.getAttribute("aria-label") ?? "").startsWith("Turn Text"))).toBe(false); + }); + + it("leaves the OTHER channels alone when one is locked", () => { + const c = setup({ lockedChannels: { sms: "off" } }); + const email = boxes(c).filter((b) => (b.getAttribute("aria-label") ?? "").endsWith("Email")); + expect(email.every((b) => !b.disabled)).toBe(true); + }); + it("renders no bulk controls at all for a single-row screen", () => { // The row, the column and the grid all resolve to the same cell there. const c = setup({ youChoose: [rows[0]] }); diff --git a/app/components/notifications/NotificationPreferences.tsx b/app/components/notifications/NotificationPreferences.tsx index eb80c1ffe..d64e2b15d 100644 --- a/app/components/notifications/NotificationPreferences.tsx +++ b/app/components/notifications/NotificationPreferences.tsx @@ -70,6 +70,16 @@ export interface NotificationPreferencesProps { * single choosable row should do, since there the one cell IS the control. */ onBulk?: (enabled: boolean, scope: { channel?: ChannelId; classId?: string }) => void; + /** + * Channels the reader cannot currently receive at all, with the reason. + * + * A revoked SMS consent makes every per-notification Text choice moot — no + * text can arrive whatever the row says — so the column is DISABLED rather + * than merely unchecked. Leaving it live would let someone tick "yes, text + * me about bookings" while consent says we may not text them at all, which + * is a screen disagreeing with the send gate. + */ + lockedChannels?: Partial>; } /** All | none | some of the cells in scope are on. */ @@ -123,13 +133,14 @@ const CHANNELS: ReadonlyArray<{ id: ChannelId; label: () => string }> = [ ]; function ChannelCell({ - row, channel, channelLabel, onChange, busy, + row, channel, channelLabel, onChange, busy, lockedReason, }: { row: ChoiceRow; channel: ChannelId; channelLabel: string; onChange: NotificationPreferencesProps["onChange"]; busy: boolean; + lockedReason?: string | undefined; }) { return (
@@ -140,8 +151,9 @@ function ChannelCell({ onChange(row.id, channel, e.currentTarget.checked)} />
@@ -150,6 +162,7 @@ function ChannelCell({ export function NotificationPreferences({ alwaysSent, youChoose, onChange, busy = false, status = "idle", onBulk, + lockedChannels = {}, }: NotificationPreferencesProps) { // With one row there is nothing to batch: the row, the column and the grid // all resolve to the same single cell, and three extra controls saying so @@ -238,7 +251,7 @@ export function NotificationPreferences({ {c.label()} - {bulk && bulkStateOf(youChoose, { channel: c.id }) && ( + {bulk && !lockedChannels[c.id] && bulkStateOf(youChoose, { channel: c.id }) && ( ))} diff --git a/app/components/notifications/SmsConsentBlock.test.tsx b/app/components/notifications/SmsConsentBlock.test.tsx index 31e8dcbe1..8aa9573d6 100644 --- a/app/components/notifications/SmsConsentBlock.test.tsx +++ b/app/components/notifications/SmsConsentBlock.test.tsx @@ -10,14 +10,24 @@ import { describe, it, expect, vi } from "vitest"; import { render } from "@testing-library/react"; import { SmsConsentBlock, type SmsConsent } from "./SmsConsentBlock"; -const base: SmsConsent = { phone: "+1 555 000 1111", state: "granted", at: "2026-06-12T00:00:00.000Z", capturedVia: "booking_form" }; +const DISCLOSURE = { version: 3, text: "Message and data rates may apply." }; +const base: SmsConsent = { + phone: "+1 555 000 1111", state: "granted", at: "2026-06-12T00:00:00.000Z", + capturedVia: "booking_form", disclosure: DISCLOSURE, +}; function setup(consent: Partial = {}, manageHref?: string) { const onStop = vi.fn(); + const onGrant = vi.fn(); const utils = render( - , + , ); - return { ...utils, onStop }; + return { ...utils, onStop, onGrant }; } describe("SMS consent block", () => { @@ -42,10 +52,31 @@ describe("SMS consent block", () => { expect(c.onStop).toHaveBeenCalled(); }); - it("offers NO way to switch consent back on, only a way out to the opt-in page", () => { - const c = setup({ state: "revoked" }, "/sms-optin/abc"); + it("offers a way BACK ON after a stop — otherwise the block is a dead end", () => { + // Shipped as a dead end and caught in the browser: stopped, no Stop button + // (correct), and no way to return (not). + const c = setup({ state: "revoked" }); expect(c.queryByText(/Stop texts/i)).toBeNull(); - expect(c.getByText(/Manage texts/i)).toBeTruthy(); + expect(c.getByText(/Turn texts on/i)).toBeTruthy(); + }); + + it("will not grant until the reader has acknowledged the disclosure", () => { + // The disclosure is on screen and its VERSION travels with the grant. That + // is the whole difference between recording consent and inventing it. + const c = setup({ state: "revoked" }); + const button = c.getByText(/Turn texts on/i).closest("button")!; + expect(button.disabled).toBe(true); + + c.container.querySelector("input[type=checkbox]")!.click(); + expect(c.getByText(/Turn texts on/i).closest("button")!.disabled).toBe(false); + c.getByText(/Turn texts on/i).closest("button")!.click(); + expect(c.onGrant).toHaveBeenCalledWith(3); + }); + + it("offers no inline grant when there is no disclosure to show", () => { + // No text means nothing the reader could have agreed to. + const c = setup({ state: "revoked", disclosure: null }); + expect(c.queryByText(/Turn texts on/i)).toBeNull(); }); it("says an agent is reachable under the relationship, without claiming a grant", () => { diff --git a/app/components/notifications/SmsConsentBlock.tsx b/app/components/notifications/SmsConsentBlock.tsx index ca3b31725..abc00f72f 100644 --- a/app/components/notifications/SmsConsentBlock.tsx +++ b/app/components/notifications/SmsConsentBlock.tsx @@ -1,4 +1,5 @@ -import { Button } from "@core/shared-ui"; +import { useState } from "react"; +import { Button, Checkbox } from "@core/shared-ui"; import { formatDate } from "~/lib/format"; import { m } from "~/paraglide/messages"; @@ -24,22 +25,33 @@ export interface SmsConsent { phone: string | null; state: SmsConsentState; at: string | null; - capturedVia: "booking_form" | "optin_link" | "admin" | null; + capturedVia: "booking_form" | "optin_link" | "admin" | "settings_page" | null; + /** What the reader must SEE before granting, and the version recorded. */ + disclosure: { version: number; text: string } | null; } const SOURCE = { + settings_page: () => m.notif_prefs_source_settings_page(), booking_form: () => m.notif_prefs_source_booking_form(), optin_link: () => m.notif_prefs_source_optin_link(), admin: () => m.notif_prefs_source_admin(), }; export function SmsConsentBlock({ - consent, manageHref, onStop, busy = false, locale = "en-US", + consent, manageHref, onStop, onGrant, busy = false, locale = "en-US", }: { consent: SmsConsent; /** The opt-in page — where consent can be granted with its disclosure. */ manageHref?: string | undefined; onStop: () => void; + /** + * Grant, with the disclosure version the reader actually saw. + * + * Absent ⇒ no inline grant is offered. The version is passed back rather + * than looked up server-side because that is the whole difference between + * recording consent and inventing it. + */ + onGrant?: ((disclosureVersion: number) => void) | undefined; busy?: boolean; /** * The APP's locale, passed in rather than read from a hook. @@ -54,6 +66,8 @@ export function SmsConsentBlock({ }) { const day = (iso: string | null) => (iso ? formatDate(iso, { locale }) : ""); const on = consent.state === "granted" || consent.state === "implied"; + const [ack, setAck] = useState(false); + const canGrant = !on && !!onGrant && !!consent.disclosure; return (
@@ -83,7 +97,36 @@ export function SmsConsentBlock({

{m.notif_prefs_sms_implied()}

)} + {canGrant && ( + // The disclosure is on screen BEFORE the acknowledgement, and + // its version travels with the grant. That is what makes an + // inline switch a record rather than a claim. +
+
+ + {m.notif_prefs_sms_disclosure_show()} + +

+ {consent.disclosure!.text} +

+
+ +
+ )} +
+ {canGrant && ( + + )} {on && (
); } diff --git a/app/lib/portal-notification-preferences.ts b/app/lib/portal-notification-preferences.ts index 0db44a5ec..581bf60a1 100644 --- a/app/lib/portal-notification-preferences.ts +++ b/app/lib/portal-notification-preferences.ts @@ -101,3 +101,44 @@ export async function bulkPortalNotificationChoice( ); return res.ok ? { ok: true } : { ok: false, error: m.portal_notif_save_error() }; } + +/** Cookie plus the two evidence headers, omitting any the request lacks. */ +function forwardedEvidence(request: Request): Record { + const ip = request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for"); + const ua = request.headers.get("user-agent"); + return { + Cookie: request.headers.get("cookie") ?? "", + ...(ip ? { "cf-connecting-ip": ip } : {}), + ...(ua ? { "user-agent": ua } : {}), + }; +} + +/** + * Record an inline SMS consent grant, carrying the version that was on screen. + * + * THE IP AND USER AGENT ARE FORWARDED EXPLICITLY, and they have to be. The BFF + * calls the API in-process over the `API_WORKER` binding, so the browser's + * `cf-connecting-ip` and `user-agent` never reach the handler on their own — + * the ledger recorded nulls for both, which are the two fields that make a + * consent row defensible in a carrier audit. Verified in the browser; nothing + * in the type system or the tests would have said a word. + */ +export async function grantPortalSmsConsent( + context: LoadContext, + tenant: string, + request: Request, + formData: FormData, +): Promise<{ ok: boolean; error?: string }> { + const api = createApi(context); + const res = await api.portalNotificationPrefs[":tenant"]["notification-preferences"]["sms-consent"].$put( + { + param: { tenant }, + json: { disclosureVersion: Number(formData.get("disclosureVersion") ?? 0) }, + }, + // Absent headers are OMITTED, not sent empty. An empty string would be + // stored as one, and a consent row claiming "we recorded an ip and it + // was blank" is worse evidence than one that plainly has none. + { headers: forwardedEvidence(request) }, + ); + return res.ok ? { ok: true } : { ok: false, error: m.portal_notif_save_error() }; +} diff --git a/app/routes/agent/settings-profile.tsx b/app/routes/agent/settings-profile.tsx index 9e6868fb0..4e29e415a 100644 --- a/app/routes/agent/settings-profile.tsx +++ b/app/routes/agent/settings-profile.tsx @@ -162,6 +162,9 @@ export default function AgentSettingsProfilePage() { const [applyAll, setApplyAll] = useState(false); // "saved" persists after the fetcher goes idle, so the confirmation is still // on screen when the reader looks up from the switch they just moved. + const sc = notifications.smsConsent; + // No consent means no text can arrive, whatever a row says. + const smsUnavailable = !!sc && (sc.state === "revoked" || sc.state === "none"); const notifyStatus = notifyFetcher.state !== "idle" ? "saving" as const : "idle" as const; const navigate = useNavigate(); const locale = useDisplayLocale(); @@ -294,16 +297,6 @@ export default function AgentSettingsProfilePage() { {m.agent_portal_settings_notify_apply_all({ count: notifications.companies.length })} )} -
- -
{notifications.smsConsent && (
)} + {notifications.smsConsent && ( +
+ bulkNotification(false, { channel: "sms" })} + busy={notifyFetcher.state !== "idle"} + /> +
+ )} +
+ +
)}
diff --git a/app/routes/public/portal-inspection.tsx b/app/routes/public/portal-inspection.tsx index d4a9f64b1..a8d4f2fc3 100644 --- a/app/routes/public/portal-inspection.tsx +++ b/app/routes/public/portal-inspection.tsx @@ -54,6 +54,7 @@ import { } from "~/lib/section-loaders"; import { bulkPortalNotificationChoice, + grantPortalSmsConsent, loadNotificationsSection, savePortalNotificationChoice, type NotificationsLoaderResult, @@ -252,6 +253,11 @@ export async function action({ request, params, context }: Route.ActionArgs) { // C3 — the Notices bell's writes. The session cookie travels explicitly // because the typed client does not forward the browser's. + if (intent === "notification-sms-grant") { + const r = await grantPortalSmsConsent(context, tenant, request, formData); + return { ...r, intent }; + } + if (intent === "notification-bulk") { const r = await bulkPortalNotificationChoice( context, tenant, request.headers.get("cookie") ?? "", formData, diff --git a/messages/en/components.json b/messages/en/components.json index a5c86b830..0989ca979 100644 --- a/messages/en/components.json +++ b/messages/en/components.json @@ -166,7 +166,12 @@ "notif_prefs_sms_none": "Texts are OFF. We have no record that you asked for them.", "notif_prefs_sms_stop_hint": "To stop, reply STOP to any message.", "notif_prefs_sms_stop": "Stop texts", + "notif_prefs_sms_grant_ack": "Yes, text me at this number.", + "notif_prefs_sms_grant": "Turn texts on", + "notif_prefs_sms_disclosure_show": "Read what you're agreeing to", + "notif_prefs_sms_locked": "Text is switched off above, so these do not apply.", "notif_prefs_sms_manage": "Manage texts", + "notif_prefs_source_settings_page": "this page", "notif_prefs_source_booking_form": "a booking form", "notif_prefs_source_optin_link": "an opt-in link", "notif_prefs_source_admin": "your inspector", diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 7e7c9497e..5a2718749 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -57,9 +57,9 @@ "app/lib/collab/results-doc-connection.ts": 435, "server/api/inspections/media.ts": 435, "app/components/media-studio/VideoCapture.tsx": 433, + "app/routes/public/portal-inspection.tsx": 430, "server/api/inspections/results.ts": 430, "app/hooks/useStructureEdit.ts": 424, - "app/routes/public/portal-inspection.tsx": 424, "server/lib/middleware/di.ts": 417, "app/routes/templates.tsx": 414, "app/routes/calendar.tsx": 410, diff --git a/server/api/agent/notification-preferences.ts b/server/api/agent/notification-preferences.ts index e3385dbee..6b8959e59 100644 --- a/server/api/agent/notification-preferences.ts +++ b/server/api/agent/notification-preferences.ts @@ -157,6 +157,7 @@ const agentNotificationPreferenceRoutes = createApiRouter() const db = getDrizzle(c); const companies = await listAgentCompanies(db, agentUserId); + const disclosure = await new SmsConsentService(c.env.DB).currentDisclosure(); const { companyId } = c.req.valid('query'); const selected = companyId ? companies.find((x) => x.tenantId === companyId) @@ -177,7 +178,7 @@ const agentNotificationPreferenceRoutes = createApiRouter() // Consent is per COMPANY, like everything else on this screen: // it attaches to the contact row that company holds. smsConsent: selected - ? await readSmsConsent(db, selected.tenantId, 'agent', [selected.contactId]) + ? await readSmsConsent(db, selected.tenantId, 'agent', [selected.contactId], disclosure) : null, }, }, 200); @@ -231,7 +232,7 @@ const agentNotificationPreferenceRoutes = createApiRouter() // A whole-channel stop is also a consent act on SMS (§4.2), and an // agent's revocation is recorded AS an agent's. if (action === 'disable' && channel === 'sms' && !classId) { - const block = await readSmsConsent(db, t.tenantId, 'agent', [t.contactId]); + const block = await readSmsConsent(db, t.tenantId, 'agent', [t.contactId], null); await revokeChannel(new SmsConsentService(c.env.DB), t.tenantId, 'sms', block, 'agent'); } } diff --git a/server/api/portal/notification-preferences.ts b/server/api/portal/notification-preferences.ts index 88c6da126..9e28c7516 100644 --- a/server/api/portal/notification-preferences.ts +++ b/server/api/portal/notification-preferences.ts @@ -8,7 +8,7 @@ import type { HonoConfig } from '../../types/hono'; import { buildScreenModel } from '../../lib/notifications/screen-model'; import { applyBulk, assertChoosable, readChoices, writeChoice } from '../../lib/notifications/preference-write'; import { contactIdsForEmail } from '../../services/notice-inbox'; -import { readSmsConsent, revokeChannel } from '../../lib/notifications/channel-consent'; +import { grantSms, readSmsConsent, revokeChannel } from '../../lib/notifications/channel-consent'; import { SmsConsentService } from '../../services/sms-consent.service'; import { Errors } from '../../lib/errors'; @@ -141,9 +141,43 @@ const bulkRoute = createRoute(withMcpMetadata({ 'notifications are never touched.', }, { scopes: [], tier: 'extended' })); +const grantRoute = createRoute(withMcpMetadata({ + method: 'put', + path: '/{tenant}/notification-preferences/sms-consent', + tags: ['public'], + summary: 'Record that this reader agreed to receive texts', + request: { + params: TenantParam, + body: { + content: { + 'application/json': { + schema: z.object({ + disclosureVersion: z.number().int() + .describe('The version of the disclosure that was on screen when they agreed.'), + }), + }, + }, + }, + }, + responses: { + 200: { + content: { 'application/json': { schema: z.object({ success: z.literal(true) }) } }, + description: 'Recorded.', + }, + 400: { description: 'No current disclosure, or a version that is not the current one' }, + 401: { description: 'No valid portal session cookie' }, + }, + operationId: 'portalGrantSmsConsent', + description: + 'Appends a granted row to the SMS consent ledger, stamped with the disclosure the ' + + 'reader saw, their ip and their user agent. A separate route from the preference ' + + 'writes because it is a legal record, not a setting.', +}, { scopes: [], tier: 'extended' })); + const router = createApiRouter(); router.use('/:tenant/notification-preferences', portalSessionGuard); router.use('/:tenant/notification-preferences/bulk', portalSessionGuard); +router.use('/:tenant/notification-preferences/sms-consent', portalSessionGuard); const portalNotificationPreferenceRoutes = router .openapi(getScreenRoute, async (c) => { @@ -161,7 +195,8 @@ const portalNotificationPreferenceRoutes = router if (!chosen.has(key) || enabled === false) chosen.set(key, enabled); } } - const smsConsent = await readSmsConsent(db, tenantId, 'client', contactIds); + const disclosure = await new SmsConsentService(c.env.DB).currentDisclosure(); + const smsConsent = await readSmsConsent(db, tenantId, 'client', contactIds, disclosure); return c.json({ success: true as const, data: { ...buildScreenModel('client', chosen), smsConsent }, @@ -204,10 +239,35 @@ const portalNotificationPreferenceRoutes = router // Switching a whole channel off is also a CONSENT act on SMS, and the // ledger has to carry it wherever the reader stopped from (§4.2). if (change.action === 'disable' && change.channel === 'sms' && !change.classId) { - const block = await readSmsConsent(db, tenantId, 'client', contactIds); + const block = await readSmsConsent(db, tenantId, 'client', contactIds, null); await revokeChannel(new SmsConsentService(c.env.DB), tenantId, 'sms', block, 'client'); } return c.json({ success: true as const }, 200); + }) + .openapi(grantRoute, async (c) => { + const tenantId = resolveTenantId(c); + if (!tenantId) throw Errors.NotFound('Company not found.'); + const { disclosureVersion } = c.req.valid('json'); + + const svc = new SmsConsentService(c.env.DB); + const disclosure = await svc.currentDisclosure(); + // The version must be the CURRENT one. A stale version means the reader + // agreed to text they are no longer being shown, and recording it would + // put a claim in the ledger the disclosure does not support. + if (!disclosure || disclosure.version !== disclosureVersion) { + throw Errors.BadRequest('Please reload and read the current terms before agreeing.'); + } + + const db = getDrizzle(c); + const contactIds = await contactIdsForEmail(db, tenantId, c.get('portalEmail') as string); + const block = await readSmsConsent(db, tenantId, 'client', contactIds, disclosure); + if (!block) throw Errors.BadRequest('There is nothing to change here.'); + + await grantSms(svc, tenantId, block, 'client', { + ip: c.req.header('cf-connecting-ip'), + userAgent: c.req.header('user-agent'), + }); + return c.json({ success: true as const }, 200); }); export default portalNotificationPreferenceRoutes; diff --git a/server/lib/db/schema/compliance.ts b/server/lib/db/schema/compliance.ts index a808cf037..96c6eb98a 100644 --- a/server/lib/db/schema/compliance.ts +++ b/server/lib/db/schema/compliance.ts @@ -67,7 +67,10 @@ export const smsConsentLog = sqliteTable('sms_consent_log', { recipientType: text('recipient_type', { enum: ['client', 'agent', 'other'] }).notNull(), action: text('action', { enum: ['granted', 'revoked'] }).notNull(), disclosureVersion: integer('disclosure_version').notNull(), - capturedVia: text('captured_via', { enum: ['booking_form', 'optin_link', 'admin'] }).notNull(), + // `settings_page` is a grant made inline on the notifications screen, with + // the disclosure rendered there. Type-layer only — the DDL is plain text, + // so widening this costs no migration. + capturedVia: text('captured_via', { enum: ['booking_form', 'optin_link', 'admin', 'settings_page'] }).notNull(), ip: text('ip'), userAgent: text('user_agent'), createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json index 3c5b9022f..4cd46e9da 100644 --- a/server/lib/mcp/openapi-snapshot.json +++ b/server/lib/mcp/openapi-snapshot.json @@ -14624,6 +14624,42 @@ "summary": "What this company sends you, and what you can switch off", "description": "Returns the notifications addressed to the signed-in recipient in this tenant, split into the ones that cannot be switched off and the ones they choose. Channels a class never uses are reported as \"unavailable\", which is not the same as \"off\"." }, + { + "operationId": "portalGrantSmsConsent", + "method": "PUT", + "pathTemplate": "/api/portal/{tenant}/notification-preferences/sms-consent", + "scopes": [], + "tag": "public", + "tier": "extended", + "inputSchema": { + "parameters": [ + { + "name": "tenant", + "in": "path", + "required": true, + "description": "Tenant slug (resolves the tenant from the URL path).", + "schema": { + "type": "string", + "description": "Tenant slug (resolves the tenant from the URL path)." + } + } + ], + "body": { + "type": "object", + "properties": { + "disclosureVersion": { + "type": "integer", + "description": "The version of the disclosure that was on screen when they agreed." + } + }, + "required": [ + "disclosureVersion" + ] + } + }, + "summary": "Record that this reader agreed to receive texts", + "description": "Appends a granted row to the SMS consent ledger, stamped with the disclosure the reader saw, their ip and their user agent. A separate route from the preference writes because it is a legal record, not a setting." + }, { "operationId": "portalInspectionObserve", "method": "GET", diff --git a/server/lib/notifications/channel-consent.ts b/server/lib/notifications/channel-consent.ts index b30e92f89..7c2897dfb 100644 --- a/server/lib/notifications/channel-consent.ts +++ b/server/lib/notifications/channel-consent.ts @@ -30,6 +30,14 @@ export interface SmsConsentBlock { capturedVia: 'booking_form' | 'optin_link' | 'admin' | null; /** The contact rows this reader is, so a Stop knows what to write. */ contactIds: string[]; + /** + * The disclosure the reader must SEE before granting, and its version. + * + * Carried with the state because granting inline is only honest if the + * text was on screen when they agreed — shipping the state without the + * text is what would turn an inline switch into manufactured evidence. + */ + disclosure: { version: number; text: string } | null; } /** @@ -48,6 +56,7 @@ export async function readSmsConsent( tenantId: string, audience: Audience, contactIds: string[], + disclosure: { version: number; text: string } | null, ): Promise { if (contactIds.length === 0) return null; @@ -75,13 +84,13 @@ export async function readSmsConsent( if (latest?.action === 'revoked') { return { phone, state: 'revoked', - at: toIso(latest.createdAt), capturedVia: latest.capturedVia ?? null, contactIds, + at: toIso(latest.createdAt), capturedVia: latest.capturedVia ?? null, contactIds, disclosure, }; } if (latest?.action === 'granted') { return { phone, state: 'granted', - at: toIso(latest.createdAt), capturedVia: latest.capturedVia ?? null, contactIds, + at: toIso(latest.createdAt), capturedVia: latest.capturedVia ?? null, contactIds, disclosure, }; } @@ -90,7 +99,7 @@ export async function readSmsConsent( // not reachable at all until they say so. return { phone, state: audience === 'agent' ? 'implied' : 'none', - at: null, capturedVia: null, contactIds, + at: null, capturedVia: null, contactIds, disclosure, }; } @@ -121,11 +130,32 @@ function toIso(v: unknown): string | null { export interface ConsentRecorder { record( tenantId: string, contactId: string, action: 'granted' | 'revoked', - capturedVia: 'booking_form' | 'optin_link' | 'admin', + capturedVia: 'booking_form' | 'optin_link' | 'admin' | 'settings_page', meta: { ip?: string | undefined; userAgent?: string | undefined; recipientType?: 'client' | 'agent' | 'other' }, ): Promise; } +/** + * Record that this reader granted the text channel, from this screen. + * + * Only legitimate when the disclosure was RENDERED and its version comes back + * with the acknowledgement — which is why the version is a parameter and not + * something this function looks up. A caller that could pass any version would + * be able to record consent to text the reader never saw. + */ +export async function grantSms( + recorder: ConsentRecorder, + tenantId: string, + block: SmsConsentBlock, + audience: Audience, + meta: { ip?: string | undefined; userAgent?: string | undefined }, +): Promise { + const recipientType = audience === 'agent' ? 'agent' as const : 'client' as const; + for (const contactId of block.contactIds) { + await recorder.record(tenantId, contactId, 'granted', 'settings_page', { ...meta, recipientType }); + } +} + export async function revokeChannel( recorder: ConsentRecorder, tenantId: string, From be6efd4042076bb27a662b9f1d23ccf6fc115012 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 31 Jul 2026 21:42:05 +0800 Subject: [PATCH 21/48] =?UTF-8?q?feat(sms):=20staff=20can=20STOP=20?= =?UTF-8?q?=E2=80=94=20a=20consent=20ledger=20keyed=20on=20a=20subject,=20?= =?UTF-8?q?not=20a=20contact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ISV strategy (2026-07-30) promised staff a "separate track — employment / account terms + STOP" and the schema could not keep the second half: `sms_consent_log.contact_id` was NOT NULL and a staff member is a `users` row with no contact, so a staff STOP had nowhere to land. The ledger now carries `subject_kind` / `subject_id`, mirroring `notification_preferences` — one shape for "a person, of either kind" rather than a second XOR of nullable columns. `recipient_type` gains `staff`, and the send gate's latest-event lookup keys on the subject, so a staff revocation is honoured by the same query that has always honoured a contact's. WHAT DELIBERATELY DID NOT CHANGE is the half a carrier asks about: only consumers ever produce a `granted` row. `grantSms` returns early for any non-client audience, so agents and staff stay implied and never enter the ledger as grants. "Show us your opt-in proof" keeps pointing at consumers alone, while nobody who says stop keeps getting texts. Both compliance documents were amended rather than left to drift — the strategy spec gains a §2.1 amendment saying what moved and what did not, and docs/sms-compliance.md now states the asymmetry out loud. The generated migration was BROKEN and is hand-edited. drizzle-kit emitted a table rebuild whose INSERT selected `subject_kind` and `subject_id` FROM the old table, where neither exists — "no such column", with DROP TABLE as the next statement. On a table holding consent evidence that is not an acceptable failure mode. The copy now supplies them as literals and backfills the subject from the contact every existing row already has. Applied locally; remote stays behind the D1 SOP backup. Two defects found on the way, both pre-existing: - `requiresExpressSmsConsent` THREW on an unrecognised role kind — indexing the basis map with a value not in it and reading `.basis` of undefined. In a compliance gate that is worse than either answer: not a refusal, not a send, but a 500 whose meaning depends on the caller. It now fails CLOSED. - two consent events recorded in the same millisecond resolved arbitrarily, because `ORDER BY created_at DESC LIMIT 1` had no tiebreak. A STOP and a START a millisecond apart could pick either. Insertion order now breaks the tie — for a consent ledger, "which one is latest" must not be a coin toss. Surfaced when the new index changed which plan SQLite chose. Not eyeballed: the staff screen's rendering. Verifying it needed a staff session I had just logged out of, and logging back in would have meant typing a saved password. Its server path is covered by `channel-consent.spec.ts`, including the staff subject specifically. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RgBRZQhGELdkaWGorWwWKV --- docs/sms-compliance.md | 9 +- migrations/0020_fancy_toxin.sql | 28 + migrations/meta/0020_snapshot.json | 10033 ++++++++++++++++ migrations/meta/_journal.json | 7 + server/api/agent/notification-preferences.ts | 4 +- server/api/notification-preferences.ts | 33 +- server/api/portal/notification-preferences.ts | 9 +- server/lib/db/schema/compliance.ts | 32 +- server/lib/notifications/channel-consent.ts | 91 +- server/lib/sms/consent-basis.ts | 33 +- server/lib/sms/send-gate.ts | 15 +- server/services/sms-consent.service.ts | 48 +- .../message-template-sms-test-send.spec.ts | 4 + tests/unit/messaging/sms-api.spec.ts | 3 + tests/unit/messaging/sms-send-gate.spec.ts | 49 +- .../notifications/channel-consent.spec.ts | 124 + 16 files changed, 10447 insertions(+), 75 deletions(-) create mode 100644 migrations/0020_fancy_toxin.sql create mode 100644 migrations/meta/0020_snapshot.json create mode 100644 tests/unit/notifications/channel-consent.spec.ts diff --git a/docs/sms-compliance.md b/docs/sms-compliance.md index 6a6ef3d06..093f224ea 100644 --- a/docs/sms-compliance.md +++ b/docs/sms-compliance.md @@ -27,7 +27,14 @@ Those effective URLs appear in booking footers, the client portal, invoices, and |---|---|---| | Clients / consumers | Express — recorded opt-in before send | Recorded opt-in; retain proof | | Agents / other parties on the job | Implied — phone on file for the transaction | Established business relationship; STOP still applies | -| Staff | Account / employment terms | Internal; not the consumer consent ledger | +| Staff | Account / employment terms | Internal; no recorded opt-in — STOP still applies | + +**STOP works for everyone, opt-in evidence is consumers only.** Agents and staff +are never asked for express consent, so no `granted` row is ever recorded for +them. A STOP from any of them IS recorded and honoured, because a request to be +left alone binds whatever basis the first message was sent under. That keeps the +answer to "show us your opt-in proof" pointing at consumers alone, which is what +a carrier is asking about, while nobody who says stop keeps getting texts. --- diff --git a/migrations/0020_fancy_toxin.sql b/migrations/0020_fancy_toxin.sql new file mode 100644 index 000000000..07cf4e8d0 --- /dev/null +++ b/migrations/0020_fancy_toxin.sql @@ -0,0 +1,28 @@ +-- HAND-EDITED. drizzle-kit generated an INSERT that selected `subject_kind` and +-- `subject_id` FROM the old table, where neither column exists yet — "no such +-- column", with DROP TABLE as the very next statement. On a table holding SMS +-- consent evidence that is not an acceptable failure mode, so the copy below +-- supplies the new columns as literals and backfills the subject from the +-- contact every existing row already has. +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_sms_consent_log` ( + `id` text PRIMARY KEY NOT NULL, + `tenant_id` text NOT NULL, + `contact_id` text, + `recipient_type` text NOT NULL, + `action` text NOT NULL, + `disclosure_version` integer NOT NULL, + `captured_via` text NOT NULL, + `ip` text, + `user_agent` text, + `created_at` integer NOT NULL, + `subject_kind` text DEFAULT 'contact' NOT NULL, + `subject_id` text DEFAULT '' NOT NULL +); +--> statement-breakpoint +INSERT INTO `__new_sms_consent_log`("id", "tenant_id", "contact_id", "recipient_type", "action", "disclosure_version", "captured_via", "ip", "user_agent", "created_at", "subject_kind", "subject_id") SELECT "id", "tenant_id", "contact_id", "recipient_type", "action", "disclosure_version", "captured_via", "ip", "user_agent", "created_at", 'contact', "contact_id" FROM `sms_consent_log`;--> statement-breakpoint +DROP TABLE `sms_consent_log`;--> statement-breakpoint +ALTER TABLE `__new_sms_consent_log` RENAME TO `sms_consent_log`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE INDEX `idx_sms_consent_contact` ON `sms_consent_log` (`tenant_id`,`contact_id`,`created_at`);--> statement-breakpoint +CREATE INDEX `idx_sms_consent_subject` ON `sms_consent_log` (`tenant_id`,`subject_kind`,`subject_id`,`created_at`); diff --git a/migrations/meta/0020_snapshot.json b/migrations/meta/0020_snapshot.json new file mode 100644 index 000000000..f49becc2e --- /dev/null +++ b/migrations/meta/0020_snapshot.json @@ -0,0 +1,10033 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "595f38f0-07a0-45bf-9483-8ff1bcd9d296", + "prevId": "0ea907f9-b228-4f46-b358-222449c8d22b", + "tables": { + "agreement_requests": { + "name": "agreement_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agreement_id": { + "name": "agreement_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "signature_base64": { + "name": "signature_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_at": { + "name": "signed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "viewed_at": { + "name": "viewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_at": { + "name": "sent_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_signature_base64": { + "name": "inspector_signature_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_signed_at": { + "name": "inspector_signed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_user_id": { + "name": "inspector_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_snapshot": { + "name": "content_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completion_policy": { + "name": "completion_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'all'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purged_at": { + "name": "purged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agreement_requests_token_unique": { + "name": "agreement_requests_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "idx_agreement_requests_verify_token": { + "name": "idx_agreement_requests_verify_token", + "columns": [ + "verification_token" + ], + "isUnique": true + }, + "idx_agreement_requests_tenant": { + "name": "idx_agreement_requests_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_agreement_requests_inspection": { + "name": "idx_agreement_requests_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "idx_agreement_requests_token_hash": { + "name": "idx_agreement_requests_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "agreement_requests_tenant_id_tenants_id_fk": { + "name": "agreement_requests_tenant_id_tenants_id_fk", + "tableFrom": "agreement_requests", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agreement_requests_inspection_id_inspections_id_fk": { + "name": "agreement_requests_inspection_id_inspections_id_fk", + "tableFrom": "agreement_requests", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agreement_requests_agreement_id_agreements_id_fk": { + "name": "agreement_requests_agreement_id_agreements_id_fk", + "tableFrom": "agreement_requests", + "tableTo": "agreements", + "columnsFrom": [ + "agreement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agreement_requests_inspector_user_id_users_id_fk": { + "name": "agreement_requests_inspector_user_id_users_id_fk", + "tableFrom": "agreement_requests", + "tableTo": "users", + "columnsFrom": [ + "inspector_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agreement_signers": { + "name": "agreement_signers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'client'" + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_enc": { + "name": "token_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "signature_base64": { + "name": "signature_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_at": { + "name": "signed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "viewed_at": { + "name": "viewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "on_behalf_of": { + "name": "on_behalf_of", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "on_behalf_disclaimer": { + "name": "on_behalf_disclaimer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_reminded_at": { + "name": "last_reminded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_agreement_signers_tenant_request": { + "name": "idx_agreement_signers_tenant_request", + "columns": [ + "tenant_id", + "request_id" + ], + "isUnique": false + }, + "idx_agreement_signers_request_email": { + "name": "idx_agreement_signers_request_email", + "columns": [ + "request_id", + "email" + ], + "isUnique": true + }, + "idx_agreement_signers_token_hash": { + "name": "idx_agreement_signers_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agreements": { + "name": "agreements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_agreements_tenant": { + "name": "idx_agreements_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "agreements_tenant_id_tenants_id_fk": { + "name": "agreements_tenant_id_tenants_id_fk", + "tableFrom": "agreements", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automation_logs": { + "name": "automation_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_role_key": { + "name": "recipient_role_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'email'" + }, + "send_at": { + "name": "send_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_contact_id": { + "name": "recipient_contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notice_id": { + "name": "notice_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_logs_pending": { + "name": "idx_automation_logs_pending", + "columns": [ + "tenant_id", + "status", + "send_at" + ], + "isUnique": false + }, + "idx_automation_logs_insp": { + "name": "idx_automation_logs_insp", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "uq_automation_logs_event": { + "name": "uq_automation_logs_event", + "columns": [ + "automation_id", + "inspection_id", + "event_id", + "channel", + "recipient" + ], + "isUnique": true, + "where": "event_id IS NOT NULL" + } + }, + "foreignKeys": { + "automation_logs_tenant_id_tenants_id_fk": { + "name": "automation_logs_tenant_id_tenants_id_fk", + "tableFrom": "automation_logs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automations": { + "name": "automations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_kind": { + "name": "recipient_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_role_profile_id": { + "name": "recipient_role_profile_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delay_minutes": { + "name": "delay_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "subject_template": { + "name": "subject_template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body_template": { + "name": "body_template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_template_id": { + "name": "email_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "conditions": { + "name": "conditions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channels": { + "name": "channels", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"email\"]'" + }, + "sms_body": { + "name": "sms_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sms_template_id": { + "name": "sms_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "in_app_template_id": { + "name": "in_app_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automations_tenant": { + "name": "idx_automations_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automations_tenant_id_tenants_id_fk": { + "name": "automations_tenant_id_tenants_id_fk", + "tableFrom": "automations", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "availability": { + "name": "availability", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_availability_inspector": { + "name": "idx_availability_inspector", + "columns": [ + "inspector_id" + ], + "isUnique": false + }, + "idx_availability_window_unique": { + "name": "idx_availability_window_unique", + "columns": [ + "inspector_id", + "day_of_week", + "start_time" + ], + "isUnique": true + } + }, + "foreignKeys": { + "availability_tenant_id_tenants_id_fk": { + "name": "availability_tenant_id_tenants_id_fk", + "tableFrom": "availability", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "availability_inspector_id_users_id_fk": { + "name": "availability_inspector_id_users_id_fk", + "tableFrom": "availability", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "availability_overrides": { + "name": "availability_overrides", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_available": { + "name": "is_available", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "start_time": { + "name": "start_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transparency": { + "name": "transparency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_avail_overrides_insp": { + "name": "idx_avail_overrides_insp", + "columns": [ + "inspector_id" + ], + "isUnique": false + }, + "idx_avail_overrides_block_unique": { + "name": "idx_avail_overrides_block_unique", + "columns": [ + "inspector_id", + "date" + ], + "isUnique": true, + "where": "is_available = 0 AND source IS NULL" + }, + "uq_avail_overrides_external": { + "name": "uq_avail_overrides_external", + "columns": [ + "inspector_id", + "source", + "external_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "availability_overrides_tenant_id_tenants_id_fk": { + "name": "availability_overrides_tenant_id_tenants_id_fk", + "tableFrom": "availability_overrides", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "availability_overrides_inspector_id_users_id_fk": { + "name": "availability_overrides_inspector_id_users_id_fk", + "tableFrom": "availability_overrides", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "calendar_blocks": { + "name": "calendar_blocks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_all_day": { + "name": "is_all_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_calendar_blocks_tenant_user_date": { + "name": "idx_calendar_blocks_tenant_user_date", + "columns": [ + "tenant_id", + "user_id", + "date" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "calendar_connection_read_calendars": { + "name": "calendar_connection_read_calendars", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_calendar_id": { + "name": "external_calendar_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_role": { + "name": "access_role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_conn_read_cal": { + "name": "uq_conn_read_cal", + "columns": [ + "connection_id", + "external_calendar_id" + ], + "isUnique": true + }, + "idx_conn_read_cal_tenant": { + "name": "idx_conn_read_cal_tenant", + "columns": [ + "tenant_id", + "connection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "calendar_connections": { + "name": "calendar_connections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials_enc": { + "name": "credentials_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials_dek_enc": { + "name": "credentials_dek_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "calendar_id": { + "name": "calendar_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connected_at": { + "name": "connected_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_calendar_connections_user_provider": { + "name": "uq_calendar_connections_user_provider", + "columns": [ + "user_id", + "provider" + ], + "isUnique": true + }, + "idx_calendar_connections_tenant_user": { + "name": "idx_calendar_connections_tenant_user", + "columns": [ + "tenant_id", + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comment_usage": { + "name": "comment_usage", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_comment_usage_user_last_used": { + "name": "idx_comment_usage_user_last_used", + "columns": [ + "tenant_id", + "user_id", + "last_used_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "comment_usage_comment_id_comments_id_fk": { + "name": "comment_usage_comment_id_comments_id_fk", + "tableFrom": "comment_usage", + "tableTo": "comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "comment_usage_tenant_id_user_id_comment_id_pk": { + "columns": [ + "tenant_id", + "user_id", + "comment_id" + ], + "name": "comment_usage_tenant_id_user_id_comment_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rating_bucket": { + "name": "rating_bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section": { + "name": "section", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_ids": { + "name": "section_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_labels": { + "name": "item_labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger_code": { + "name": "trigger_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "search_keywords": { + "name": "search_keywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_label": { + "name": "item_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repair_summary": { + "name": "repair_summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimate_min_cents": { + "name": "estimate_min_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimate_max_cents": { + "name": "estimate_max_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recommended_contractor_type_id": { + "name": "recommended_contractor_type_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_comments_tenant": { + "name": "idx_comments_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_comments_rating_bucket": { + "name": "idx_comments_rating_bucket", + "columns": [ + "tenant_id", + "rating_bucket" + ], + "isUnique": false + }, + "idx_comments_library_id": { + "name": "idx_comments_library_id", + "columns": [ + "library_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "comments_tenant_id_tenants_id_fk": { + "name": "comments_tenant_id_tenants_id_fk", + "tableFrom": "comments", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commercial_subtypes": { + "name": "commercial_subtypes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "based_on": { + "name": "based_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_disabled": { + "name": "is_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_commercial_subtypes_tenant_name": { + "name": "idx_commercial_subtypes_tenant_name", + "columns": [ + "tenant_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "commercial_subtypes_tenant_id_tenants_id_fk": { + "name": "commercial_subtypes_tenant_id_tenants_id_fk", + "tableFrom": "commercial_subtypes", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "concierge_confirm_tokens": { + "name": "concierge_confirm_tokens", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_concierge_tokens_expiry": { + "name": "idx_concierge_tokens_expiry", + "columns": [ + "expires_at" + ], + "isUnique": false + }, + "idx_concierge_confirm_token_hash": { + "name": "idx_concierge_confirm_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "concierge_confirm_tokens_inspection_id_inspections_id_fk": { + "name": "concierge_confirm_tokens_inspection_id_inspections_id_fk", + "tableFrom": "concierge_confirm_tokens", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contact_role_profiles": { + "name": "contact_role_profiles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_template_id": { + "name": "email_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sms_template_id": { + "name": "sms_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capability_overrides": { + "name": "capability_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_crp_tenant": { + "name": "idx_crp_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_crp_tenant_key": { + "name": "uq_crp_tenant_key", + "columns": [ + "tenant_id", + "key" + ], + "isUnique": true, + "where": "is_active = 1" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contacts": { + "name": "contacts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'client'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agency": { + "name": "agency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_user_id": { + "name": "agent_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_linked_at": { + "name": "agent_linked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_revoked_at": { + "name": "agent_revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_contacts_type": { + "name": "idx_contacts_type", + "columns": [ + "tenant_id", + "type" + ], + "isUnique": false + }, + "idx_contacts_tenant": { + "name": "idx_contacts_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_contacts_tenant_email": { + "name": "uq_contacts_tenant_email", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": true, + "where": "email IS NOT NULL AND archived_at IS NULL" + }, + "uq_contacts_tenant_agent_user": { + "name": "uq_contacts_tenant_agent_user", + "columns": [ + "tenant_id", + "agent_user_id" + ], + "isUnique": true, + "where": "agent_user_id IS NOT NULL AND archived_at IS NULL" + }, + "idx_contacts_agent_user": { + "name": "idx_contacts_agent_user", + "columns": [ + "agent_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "contacts_tenant_id_tenants_id_fk": { + "name": "contacts_tenant_id_tenants_id_fk", + "tableFrom": "contacts", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contractor_types": { + "name": "contractor_types", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_contractor_types_tenant": { + "name": "idx_contractor_types_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cost_items": { + "name": "cost_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "building_id": { + "name": "building_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_index": { + "name": "instance_index", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit_id": { + "name": "unit_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finding_key": { + "name": "finding_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "system": { + "name": "system", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "component": { + "name": "component", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cost_method": { + "name": "cost_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uom": { + "name": "uom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit_cost_cents": { + "name": "unit_cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lump_sum_cents": { + "name": "lump_sum_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "eul": { + "name": "eul", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "eff_age": { + "name": "eff_age", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rul": { + "name": "rul", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suggested_remedy": { + "name": "suggested_remedy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_ref": { + "name": "section_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "photo_ref": { + "name": "photo_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_cost_items_tenant_inspection": { + "name": "idx_cost_items_tenant_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "idx_cost_items_finding_key": { + "name": "idx_cost_items_finding_key", + "columns": [ + "finding_key" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "defect_categories": { + "name": "defect_categories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#6b7280'" + }, + "is_summary_driver": { + "name": "is_summary_driver", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_seed": { + "name": "is_seed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_defect_categories_tenant": { + "name": "idx_defect_categories_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "discount_codes": { + "name": "discount_codes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uses_count": { + "name": "uses_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_discount_codes_tenant": { + "name": "idx_discount_codes_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_discount_codes_code_tenant": { + "name": "uq_discount_codes_code_tenant", + "columns": [ + "upper(code)", + "tenant_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "discount_codes_tenant_id_tenants_id_fk": { + "name": "discount_codes_tenant_id_tenants_id_fk", + "tableFrom": "discount_codes", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_review_items": { + "name": "document_review_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_key": { + "name": "document_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_requested": { + "name": "is_requested", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_received": { + "name": "is_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_reviewed": { + "name": "is_reviewed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_na": { + "name": "is_na", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_doc_review_inspection": { + "name": "idx_doc_review_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "uq_doc_review_item": { + "name": "uq_doc_review_item", + "columns": [ + "inspection_id", + "document_key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_suppressions": { + "name": "email_suppressions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_provider": { + "name": "source_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_event_id": { + "name": "provider_event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_email_suppressions_email": { + "name": "idx_email_suppressions_email", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "erasure_log": { + "name": "erasure_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_email": { + "name": "subject_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identity_basis": { + "name": "identity_basis", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decisions_json": { + "name": "decisions_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retained_count": { + "name": "retained_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "anonymized_count": { + "name": "anonymized_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "deleted_count": { + "name": "deleted_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_note": { + "name": "response_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_erasure_log_tenant": { + "name": "idx_erasure_log_tenant", + "columns": [ + "tenant_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "esign_audit_logs": { + "name": "esign_audit_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prev_hash": { + "name": "prev_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_fingerprint": { + "name": "key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_esign_audit_logs_request": { + "name": "idx_esign_audit_logs_request", + "columns": [ + "tenant_id", + "request_id", + "created_at" + ], + "isUnique": false + }, + "idx_esign_audit_logs_event_dedup": { + "name": "idx_esign_audit_logs_event_dedup", + "columns": [ + "tenant_id", + "request_id", + "event" + ], + "isUnique": true, + "where": "event NOT LIKE 'signer.%'" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_types": { + "name": "event_types", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_duration_min": { + "name": "default_duration_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "default_price_cents": { + "name": "default_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#6366f1'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_event_types_tenant_slug": { + "name": "uq_event_types_tenant_slug", + "columns": [ + "tenant_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_types_tenant_id_tenants_id_fk": { + "name": "event_types_tenant_id_tenants_id_fk", + "tableFrom": "event_types", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_access_tokens": { + "name": "inspection_access_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'client'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_enc": { + "name": "token_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_iat_token": { + "name": "idx_iat_token", + "columns": [ + "token" + ], + "isUnique": true + }, + "idx_iat_inspection": { + "name": "idx_iat_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "idx_iat_recipient": { + "name": "idx_iat_recipient", + "columns": [ + "inspection_id", + "recipient_email" + ], + "isUnique": true + }, + "idx_iat_token_hash": { + "name": "idx_iat_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "inspection_access_tokens_tenant_id_tenants_id_fk": { + "name": "inspection_access_tokens_tenant_id_tenants_id_fk", + "tableFrom": "inspection_access_tokens", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_events": { + "name": "inspection_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type_id": { + "name": "event_type_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_min": { + "name": "duration_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'scheduled'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "results_received_at": { + "name": "results_received_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gcal_event_id": { + "name": "gcal_event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_events_scheduled": { + "name": "idx_inspection_events_scheduled", + "columns": [ + "tenant_id", + "scheduled_at" + ], + "isUnique": false + }, + "idx_inspection_events_inspection": { + "name": "idx_inspection_events_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspection_events_tenant_id_tenants_id_fk": { + "name": "inspection_events_tenant_id_tenants_id_fk", + "tableFrom": "inspection_events", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_events_inspection_id_inspections_id_fk": { + "name": "inspection_events_inspection_id_inspections_id_fk", + "tableFrom": "inspection_events", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inspection_events_event_type_id_event_types_id_fk": { + "name": "inspection_events_event_type_id_event_types_id_fk", + "tableFrom": "inspection_events", + "tableTo": "event_types", + "columnsFrom": [ + "event_type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_events_inspector_id_users_id_fk": { + "name": "inspection_events_inspector_id_users_id_fk", + "tableFrom": "inspection_events", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_inspectors": { + "name": "inspection_inspectors", + "columns": { + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'lead'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_insp_inspectors_tenant_user": { + "name": "idx_insp_inspectors_tenant_user", + "columns": [ + "tenant_id", + "user_id" + ], + "isUnique": false + }, + "idx_insp_inspectors_user": { + "name": "idx_insp_inspectors_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "inspection_inspectors_inspection_id_user_id_pk": { + "columns": [ + "inspection_id", + "user_id" + ], + "name": "inspection_inspectors_inspection_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_item_tag_links": { + "name": "inspection_item_tag_links", + "columns": { + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_tag_links_tenant": { + "name": "idx_tag_links_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_tag_links_tag": { + "name": "idx_tag_links_tag", + "columns": [ + "tag_id" + ], + "isUnique": false + }, + "idx_tag_links_inspection_item": { + "name": "idx_tag_links_inspection_item", + "columns": [ + "inspection_id", + "item_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "inspection_item_tag_links_inspection_id_item_id_tag_id_pk": { + "columns": [ + "inspection_id", + "item_id", + "tag_id" + ], + "name": "inspection_item_tag_links_inspection_id_item_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_media_pool": { + "name": "inspection_media_pool", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exif_data": { + "name": "exif_data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "annotations": { + "name": "annotations", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'photo'" + }, + "stream_uid": { + "name": "stream_uid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "poster_pct": { + "name": "poster_pct", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_sec": { + "name": "duration_sec", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'stream'" + }, + "poster_key": { + "name": "poster_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_media_pool_tenant": { + "name": "idx_media_pool_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_media_pool_inspection": { + "name": "idx_media_pool_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_messages": { + "name": "inspection_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "from_role": { + "name": "from_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachments": { + "name": "attachments", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_user_id": { + "name": "from_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_msg_inspection": { + "name": "idx_msg_inspection", + "columns": [ + "inspection_id", + "created_at" + ], + "isUnique": false + }, + "idx_msg_contact": { + "name": "idx_msg_contact", + "columns": [ + "tenant_id", + "contact_id", + "created_at" + ], + "isUnique": false + }, + "idx_msg_unread": { + "name": "idx_msg_unread", + "columns": [ + "tenant_id", + "contact_id", + "from_role" + ], + "isUnique": false, + "where": "\"inspection_messages\".\"read_at\" IS NULL" + } + }, + "foreignKeys": { + "inspection_messages_tenant_id_tenants_id_fk": { + "name": "inspection_messages_tenant_id_tenants_id_fk", + "tableFrom": "inspection_messages", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_messages_inspection_id_inspections_id_fk": { + "name": "inspection_messages_inspection_id_inspections_id_fk", + "tableFrom": "inspection_messages", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_people": { + "name": "inspection_people", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_profile_id": { + "name": "role_profile_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_ip_inspection": { + "name": "idx_ip_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "idx_ip_tenant": { + "name": "idx_ip_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_ip_insp_contact_role": { + "name": "uq_ip_insp_contact_role", + "columns": [ + "inspection_id", + "contact_id", + "role_profile_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_requests": { + "name": "inspection_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_phone": { + "name": "client_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_address": { + "name": "property_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "property_city": { + "name": "property_city", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_state": { + "name": "property_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_zip": { + "name": "property_zip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_amount_cents": { + "name": "total_amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unpaid'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_requests_tenant": { + "name": "idx_inspection_requests_tenant", + "columns": [ + "tenant_id", + "status", + "scheduled_at" + ], + "isUnique": false + }, + "idx_inspection_requests_email": { + "name": "idx_inspection_requests_email", + "columns": [ + "tenant_id", + "client_email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspection_requests_tenant_id_tenants_id_fk": { + "name": "inspection_requests_tenant_id_tenants_id_fk", + "tableFrom": "inspection_requests", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_results": { + "name": "inspection_results", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ydoc_state": { + "name": "ydoc_state", + "type": "blob", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating_system_id": { + "name": "rating_system_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rating_system_snapshot": { + "name": "rating_system_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_results_tenant": { + "name": "idx_results_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_results_inspection": { + "name": "idx_results_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "uq_results_inspection": { + "name": "uq_results_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "inspection_results_tenant_id_tenants_id_fk": { + "name": "inspection_results_tenant_id_tenants_id_fk", + "tableFrom": "inspection_results", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_results_inspection_id_inspections_id_fk": { + "name": "inspection_results_inspection_id_inspections_id_fk", + "tableFrom": "inspection_results", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_services": { + "name": "inspection_services", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_id": { + "name": "service_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_override_cents": { + "name": "price_override_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name_snapshot": { + "name": "name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_snapshot_cents": { + "name": "price_snapshot_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_insp_services_tenant": { + "name": "idx_insp_services_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_insp_services_insp": { + "name": "idx_insp_services_insp", + "columns": [ + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspection_services_tenant_id_tenants_id_fk": { + "name": "inspection_services_tenant_id_tenants_id_fk", + "tableFrom": "inspection_services", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_services_inspection_id_inspections_id_fk": { + "name": "inspection_services_inspection_id_inspections_id_fk", + "tableFrom": "inspection_services", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inspection_services_service_id_services_id_fk": { + "name": "inspection_services_service_id_services_id_fk", + "tableFrom": "inspection_services", + "tableTo": "services", + "columnsFrom": [ + "service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_types": { + "name": "inspection_types", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "based_on": { + "name": "based_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_types_tenant_name": { + "name": "idx_inspection_types_tenant_name", + "columns": [ + "tenant_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_units": { + "name": "inspection_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_unit_id": { + "name": "parent_unit_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unit'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "attrs": { + "name": "attrs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_units_tenant_inspection": { + "name": "idx_inspection_units_tenant_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "idx_inspection_units_parent": { + "name": "idx_inspection_units_parent", + "columns": [ + "parent_unit_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspections": { + "name": "inspections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_address": { + "name": "property_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address_place_id": { + "name": "address_place_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_street": { + "name": "address_street", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_city": { + "name": "address_city", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_state": { + "name": "address_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_zip": { + "name": "address_zip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_county": { + "name": "address_county", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_lat": { + "name": "address_lat", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_lng": { + "name": "address_lng", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_geocoded_at": { + "name": "address_geocoded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'requested'" + }, + "report_status": { + "name": "report_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'in_progress'" + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unpaid'" + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancel_notes": { + "name": "cancel_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_payment_required": { + "name": "is_payment_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_agreement_required": { + "name": "is_agreement_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_auto_sign_on_publish": { + "name": "is_auto_sign_on_publish", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "discount_code_id": { + "name": "discount_code_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "discount_amount_cents": { + "name": "discount_amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "closing_date": { + "name": "closing_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referral_source": { + "name": "referral_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_number": { + "name": "reference_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "internal_notes": { + "name": "internal_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "year_built": { + "name": "year_built", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sqft": { + "name": "sqft", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "foundation_type": { + "name": "foundation_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bedrooms": { + "name": "bedrooms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bathrooms": { + "name": "bathrooms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lot_size": { + "name": "lot_size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_facts": { + "name": "property_facts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_photo_id": { + "name": "cover_photo_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_crop": { + "name": "cover_crop", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_image_key": { + "name": "cover_image_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commercial_subtype": { + "name": "commercial_subtype", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report_tier": { + "name": "report_tier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "county": { + "name": "county", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_automations_disabled": { + "name": "is_automations_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "template_snapshot": { + "name": "template_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_snapshot_version": { + "name": "template_snapshot_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "profile_override": { + "name": "profile_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "require_defect_fields_override": { + "name": "require_defect_fields_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "concierge_status": { + "name": "concierge_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_team_mode": { + "name": "is_team_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "lead_inspector_id": { + "name": "lead_inspector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "helper_inspector_ids": { + "name": "helper_inspector_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "data_version": { + "name": "data_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "source_inspection_id": { + "name": "source_inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "root_inspection_id": { + "name": "root_inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reinspection_round": { + "name": "reinspection_round", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit_inspection_mode": { + "name": "unit_inspection_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'tagged'" + }, + "location_options": { + "name": "location_options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sampling_declaration": { + "name": "sampling_declaration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pca_narrative": { + "name": "pca_narrative", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deviations": { + "name": "deviations", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report_photo_mode": { + "name": "report_photo_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_start_ms": { + "name": "scheduled_start_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_end_ms": { + "name": "scheduled_end_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_min": { + "name": "duration_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "badge_layout_override": { + "name": "badge_layout_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report_photo_columns": { + "name": "report_photo_columns", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referred_by_contact_id": { + "name": "referred_by_contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_inspections_tenant": { + "name": "idx_inspections_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_inspections_request": { + "name": "idx_inspections_request", + "columns": [ + "request_id" + ], + "isUnique": false + }, + "idx_inspections_inspector": { + "name": "idx_inspections_inspector", + "columns": [ + "inspector_id" + ], + "isUnique": false + }, + "idx_inspections_tenant_status": { + "name": "idx_inspections_tenant_status", + "columns": [ + "tenant_id", + "status" + ], + "isUnique": false + }, + "idx_inspections_tenant_date": { + "name": "idx_inspections_tenant_date", + "columns": [ + "tenant_id", + "date" + ], + "isUnique": false + }, + "idx_inspections_inspector_date": { + "name": "idx_inspections_inspector_date", + "columns": [ + "inspector_id", + "date" + ], + "isUnique": false + }, + "idx_inspections_root": { + "name": "idx_inspections_root", + "columns": [ + "root_inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspections_tenant_id_tenants_id_fk": { + "name": "inspections_tenant_id_tenants_id_fk", + "tableFrom": "inspections", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_inspector_id_users_id_fk": { + "name": "inspections_inspector_id_users_id_fk", + "tableFrom": "inspections", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_template_id_templates_id_fk": { + "name": "inspections_template_id_templates_id_fk", + "tableFrom": "inspections", + "tableTo": "templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_discount_code_id_discount_codes_id_fk": { + "name": "inspections_discount_code_id_discount_codes_id_fk", + "tableFrom": "inspections", + "tableTo": "discount_codes", + "columnsFrom": [ + "discount_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_request_id_inspection_requests_id_fk": { + "name": "inspections_request_id_inspection_requests_id_fk", + "tableFrom": "inspections", + "tableTo": "inspection_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspector_credentials": { + "name": "inspector_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "member_number": { + "name": "member_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_r2_key": { + "name": "image_r2_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspector_credentials_tenant": { + "name": "idx_inspector_credentials_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_inspector_credentials_user": { + "name": "idx_inspector_credentials_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invoices": { + "name": "invoices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "line_items": { + "name": "line_items", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_at": { + "name": "sent_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_at": { + "name": "paid_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method": { + "name": "payment_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "partial_paid_at": { + "name": "partial_paid_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voided_at": { + "name": "voided_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "qbo_sync_status": { + "name": "qbo_sync_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + } + }, + "indexes": { + "idx_invoices_tenant": { + "name": "idx_invoices_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_invoices_inspection": { + "name": "idx_invoices_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "idx_invoices_contact": { + "name": "idx_invoices_contact", + "columns": [ + "tenant_id", + "contact_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invoices_tenant_id_tenants_id_fk": { + "name": "invoices_tenant_id_tenants_id_fk", + "tableFrom": "invoices", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invoices_inspection_id_inspections_id_fk": { + "name": "invoices_inspection_id_inspections_id_fk", + "tableFrom": "invoices", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invoices_contact_id_contacts_id_fk": { + "name": "invoices_contact_id_contacts_id_fk", + "tableFrom": "invoices", + "tableTo": "contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "marketplace_libraries": { + "name": "marketplace_libraries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "changelog": { + "name": "changelog", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_count": { + "name": "download_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_featured": { + "name": "is_featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_marketplace_libraries_kind_featured": { + "name": "idx_marketplace_libraries_kind_featured", + "columns": [ + "kind", + "is_featured" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "marketplace_templates": { + "name": "marketplace_templates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "changelog": { + "name": "changelog", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_count": { + "name": "download_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_featured": { + "name": "is_featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "message_templates": { + "name": "message_templates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "variables": { + "name": "variables", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_seeded": { + "name": "is_seeded", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_message_templates_tenant_channel": { + "name": "idx_message_templates_tenant_channel", + "columns": [ + "tenant_id", + "channel" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messaging_compliance": { + "name": "messaging_compliance", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'own'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subaccount_sid": { + "name": "subaccount_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_profile_sid": { + "name": "customer_profile_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_profile_status": { + "name": "customer_profile_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "brand_sid": { + "name": "brand_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "brand_status": { + "name": "brand_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "campaign_sid": { + "name": "campaign_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "campaign_status": { + "name": "campaign_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tfv_sid": { + "name": "tfv_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tfv_status": { + "name": "tfv_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "messaging_resource_sid": { + "name": "messaging_resource_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_meta": { + "name": "provider_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provisioned_number": { + "name": "provisioned_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provisioned_number_sid": { + "name": "provisioned_number_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "has_sender_attached": { + "name": "has_sender_attached", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "compliance_status": { + "name": "compliance_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'not_started'" + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_preferences": { + "name": "notification_preferences", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_kind": { + "name": "subject_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "class_id": { + "name": "class_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_notification_prefs_unique": { + "name": "idx_notification_prefs_unique", + "columns": [ + "tenant_id", + "subject_kind", + "subject_id", + "class_id", + "channel" + ], + "isUnique": true + }, + "idx_notification_prefs_subject": { + "name": "idx_notification_prefs_subject", + "columns": [ + "tenant_id", + "subject_kind", + "subject_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "orphaned_media": { + "name": "orphaned_media", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_orphaned_media_key": { + "name": "idx_orphaned_media_key", + "columns": [ + "tenant_id", + "r2_key" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "processed_webhook_events": { + "name": "processed_webhook_events", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "psq_responses": { + "name": "psq_responses", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "responses": { + "name": "responses", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'sent'" + }, + "share_token": { + "name": "share_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_at": { + "name": "sent_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_psq_inspection": { + "name": "uq_psq_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": true + }, + "idx_psq_share_token": { + "name": "idx_psq_share_token", + "columns": [ + "share_token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "qbo_connections": { + "name": "qbo_connections", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "realm_id": { + "name": "realm_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_enc": { + "name": "refresh_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_sync_enabled": { + "name": "is_sync_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "default_item_id": { + "name": "default_item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "qbo_entity_map": { + "name": "qbo_entity_map", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_type": { + "name": "oi_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_id": { + "name": "oi_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qbo_type": { + "name": "qbo_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qbo_id": { + "name": "qbo_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qbo_sync_token": { + "name": "qbo_sync_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "synced_at": { + "name": "synced_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_qbo_entity_map_qbo": { + "name": "idx_qbo_entity_map_qbo", + "columns": [ + "tenant_id", + "qbo_type", + "qbo_id" + ], + "isUnique": true + }, + "idx_qbo_entity_map_oi": { + "name": "idx_qbo_entity_map_oi", + "columns": [ + "tenant_id", + "oi_type", + "oi_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "qbo_sync_errors": { + "name": "qbo_sync_errors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_type": { + "name": "oi_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_id": { + "name": "oi_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_msg": { + "name": "error_msg", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_resolved": { + "name": "is_resolved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rating_systems": { + "name": "rating_systems", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "levels": { + "name": "levels", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_seed": { + "name": "is_seed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_rating_systems_tenant_slug": { + "name": "idx_rating_systems_tenant_slug", + "columns": [ + "tenant_id", + "slug" + ], + "isUnique": true + }, + "idx_rating_systems_tenant": { + "name": "idx_rating_systems_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "rating_systems_tenant_id_tenants_id_fk": { + "name": "rating_systems_tenant_id_tenants_id_fk", + "tableFrom": "rating_systems", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repair_request_items": { + "name": "repair_request_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repair_request_id": { + "name": "repair_request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finding_key": { + "name": "finding_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_title": { + "name": "section_title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_label": { + "name": "item_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "comment_snapshot": { + "name": "comment_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requested_credit_cents": { + "name": "requested_credit_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "defect_title_snapshot": { + "name": "defect_title_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_snapshot": { + "name": "location_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_snapshot": { + "name": "category_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trade_snapshot": { + "name": "trade_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_repair_request_items_rr": { + "name": "idx_repair_request_items_rr", + "columns": [ + "repair_request_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repair_requests": { + "name": "repair_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_kind": { + "name": "created_by_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_ref": { + "name": "created_by_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "custom_intro": { + "name": "custom_intro", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "share_token": { + "name": "share_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_repair_requests_inspection": { + "name": "idx_repair_requests_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "idx_repair_requests_share_token": { + "name": "idx_repair_requests_share_token", + "columns": [ + "share_token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_exports": { + "name": "report_exports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_report_exports_inspection": { + "name": "idx_report_exports_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_pdfs": { + "name": "report_pdfs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rendered_at": { + "name": "rendered_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_version": { + "name": "source_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ready'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_report_pdfs_inspection_type": { + "name": "uq_report_pdfs_inspection_type", + "columns": [ + "inspection_id", + "type", + "version_number" + ], + "isUnique": true + }, + "idx_report_pdfs_tenant": { + "name": "idx_report_pdfs_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_report_pdfs_status": { + "name": "idx_report_pdfs_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_report_pdfs_content_hash": { + "name": "idx_report_pdfs_content_hash", + "columns": [ + "inspection_id", + "type", + "content_hash" + ], + "isUnique": false + } + }, + "foreignKeys": { + "report_pdfs_tenant_id_tenants_id_fk": { + "name": "report_pdfs_tenant_id_tenants_id_fk", + "tableFrom": "report_pdfs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_signoff": { + "name": "report_signoff", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "person_id": { + "name": "person_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "license": { + "name": "license", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "qualifications_ref": { + "name": "qualifications_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_at": { + "name": "signed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signature_ref": { + "name": "signature_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_dual_role": { + "name": "is_dual_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "idx_report_signoff_inspection": { + "name": "idx_report_signoff_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "uq_report_signoff_role": { + "name": "uq_report_signoff_role", + "columns": [ + "inspection_id", + "role" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_versions": { + "name": "report_versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_json": { + "name": "snapshot_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prev_hash": { + "name": "prev_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_fingerprint": { + "name": "key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_amendment": { + "name": "is_amendment", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "idx_report_versions_inspection": { + "name": "idx_report_versions_inspection", + "columns": [ + "inspection_id", + "version_number" + ], + "isUnique": false + }, + "uq_report_versions_inspection_version": { + "name": "uq_report_versions_inspection_version", + "columns": [ + "inspection_id", + "version_number" + ], + "isUnique": true + }, + "idx_report_versions_verify_token": { + "name": "idx_report_versions_verify_token", + "columns": [ + "verification_token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "service_inspectors": { + "name": "service_inspectors", + "columns": { + "service_id": { + "name": "service_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_service_inspectors_tenant": { + "name": "idx_service_inspectors_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "service_inspectors_service_id_user_id_pk": { + "columns": [ + "service_id", + "user_id" + ], + "name": "service_inspectors_service_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "services": { + "name": "services", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agreement_id": { + "name": "agreement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_services_tenant": { + "name": "idx_services_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "services_tenant_id_tenants_id_fk": { + "name": "services_tenant_id_tenants_id_fk", + "tableFrom": "services", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "services_template_id_templates_id_fk": { + "name": "services_template_id_templates_id_fk", + "tableFrom": "services", + "tableTo": "templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "services_agreement_id_agreements_id_fk": { + "name": "services_agreement_id_agreements_id_fk", + "tableFrom": "services", + "tableTo": "agreements", + "columnsFrom": [ + "agreement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "signing_keys": { + "name": "signing_keys", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key_enc": { + "name": "private_key_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key_iv": { + "name": "private_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Ed25519'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "signing_keys_tenant_id_tenants_id_fk": { + "name": "signing_keys_tenant_id_tenants_id_fk", + "tableFrom": "signing_keys", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sms_consent_log": { + "name": "sms_consent_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_type": { + "name": "recipient_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "disclosure_version": { + "name": "disclosure_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "captured_via": { + "name": "captured_via", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_kind": { + "name": "subject_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'contact'" + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + } + }, + "indexes": { + "idx_sms_consent_contact": { + "name": "idx_sms_consent_contact", + "columns": [ + "tenant_id", + "contact_id", + "created_at" + ], + "isUnique": false + }, + "idx_sms_consent_subject": { + "name": "idx_sms_consent_subject", + "columns": [ + "tenant_id", + "subject_kind", + "subject_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sms_delivery_status": { + "name": "sms_delivery_status", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_sms_delivery_status_msg": { + "name": "idx_sms_delivery_status_msg", + "columns": [ + "tenant_id", + "provider_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sms_disclosure_versions": { + "name": "sms_disclosure_versions", + "columns": { + "version": { + "name": "version", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_seed": { + "name": "is_seed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_tags_tenant_name": { + "name": "idx_tags_tenant_name", + "columns": [ + "tenant_id", + "name" + ], + "isUnique": true + }, + "idx_tags_tenant": { + "name": "idx_tags_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "templates": { + "name": "templates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating_system_id": { + "name": "rating_system_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commercial_subtype": { + "name": "commercial_subtype", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_featured": { + "name": "is_featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_profile_id": { + "name": "default_profile_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_templates_tenant": { + "name": "idx_templates_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_templates_rating_system": { + "name": "idx_templates_rating_system", + "columns": [ + "rating_system_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "templates_tenant_id_tenants_id_fk": { + "name": "templates_tenant_id_tenants_id_fk", + "tableFrom": "templates", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_custom_holidays": { + "name": "tenant_custom_holidays", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_tenant_custom_holidays_tenant_date": { + "name": "uq_tenant_custom_holidays_tenant_date", + "columns": [ + "tenant_id", + "date" + ], + "isUnique": true + }, + "idx_tenant_custom_holidays_tenant_date": { + "name": "idx_tenant_custom_holidays_tenant_date", + "columns": [ + "tenant_id", + "date" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_library_imports": { + "name": "tenant_library_imports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_semver": { + "name": "imported_semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_at": { + "name": "imported_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "uq_tenant_library_import": { + "name": "uq_tenant_library_import", + "columns": [ + "tenant_id", + "library_id" + ], + "isUnique": true + }, + "idx_tenant_library_imports_tenant": { + "name": "idx_tenant_library_imports_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_marketplace_import_history": { + "name": "tenant_marketplace_import_history", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_version": { + "name": "source_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_version": { + "name": "target_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rows_affected": { + "name": "rows_affected", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_marketplace_history_tenant": { + "name": "idx_marketplace_history_tenant", + "columns": [ + "tenant_id", + "created_at" + ], + "isUnique": false + }, + "idx_marketplace_history_template": { + "name": "idx_marketplace_history_template", + "columns": [ + "template_id" + ], + "isUnique": false + }, + "idx_marketplace_history_library": { + "name": "idx_marketplace_history_library", + "columns": [ + "library_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_marketplace_imports": { + "name": "tenant_marketplace_imports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "marketplace_template_id": { + "name": "marketplace_template_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_semver": { + "name": "imported_semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_template_id": { + "name": "local_template_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_at": { + "name": "imported_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_mkt_imports_tmpl": { + "name": "idx_mkt_imports_tmpl", + "columns": [ + "marketplace_template_id" + ], + "isUnique": false + }, + "idx_mkt_imports_tenant": { + "name": "idx_mkt_imports_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk": { + "name": "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk", + "tableFrom": "tenant_marketplace_imports", + "tableTo": "marketplace_templates", + "columnsFrom": [ + "marketplace_template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tenant_marketplace_imports_local_template_id_templates_id_fk": { + "name": "tenant_marketplace_imports_local_template_id_templates_id_fk", + "tableFrom": "tenant_marketplace_imports", + "tableTo": "templates", + "columnsFrom": [ + "local_template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_counters": { + "name": "usage_counters", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period_key": { + "name": "period_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_usage_counters_tenant": { + "name": "idx_usage_counters_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_counters_tenant_id_metric_period_key_pk": { + "columns": [ + "tenant_id", + "metric", + "period_key" + ], + "name": "usage_counters_tenant_id_metric_period_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_templates": { + "name": "email_templates", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "blocks": { + "name": "blocks", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "email_templates_tenant_id_tenants_id_fk": { + "name": "email_templates_tenant_id_tenants_id_fk", + "tableFrom": "email_templates", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_templates_tenant_id_trigger_pk": { + "columns": [ + "tenant_id", + "trigger" + ], + "name": "email_templates_tenant_id_trigger_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_configs": { + "name": "tenant_configs", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "support_email": { + "name": "support_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_address": { + "name": "company_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_pdf_footer_shown": { + "name": "is_pdf_footer_shown", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_pdf_page_numbers_shown": { + "name": "is_pdf_page_numbers_shown", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_pdf_license_shown": { + "name": "is_pdf_license_shown", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sender_email": { + "name": "sender_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reply_to": { + "name": "reply_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_mode": { + "name": "email_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'platform'" + }, + "video_mode": { + "name": "video_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'r2'" + }, + "sms_mode": { + "name": "sms_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'platform'" + }, + "sender_display_name": { + "name": "sender_display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "point_of_contact": { + "name": "point_of_contact", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'company'" + }, + "billing_url": { + "name": "billing_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_url": { + "name": "review_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_phone": { + "name": "company_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "integration_config": { + "name": "integration_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secrets_enc": { + "name": "secrets_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dek_enc": { + "name": "dek_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ics_token": { + "name": "ics_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "widget_allowed_origins": { + "name": "widget_allowed_origins", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_profile_id": { + "name": "default_profile_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'signature'" + }, + "attention_thresholds": { + "name": "attention_thresholds", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"agreement_unsigned_h\":72,\"invoice_overdue_h\":72,\"report_unpublished_h\":72}'" + }, + "inspection_prefs": { + "name": "inspection_prefs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_estimates_shown": { + "name": "is_estimates_shown", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_repair_list_enabled": { + "name": "is_repair_list_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_customer_repair_export_enabled": { + "name": "is_customer_repair_export_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_unpaid_blocked": { + "name": "is_unpaid_blocked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_unsigned_agreement_blocked": { + "name": "is_unsigned_agreement_blocked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "custom_referral_sources": { + "name": "custom_referral_sources", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dashboard_column_prefs": { + "name": "dashboard_column_prefs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_concierge_review_required": { + "name": "is_concierge_review_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_inspector_choice_allowed": { + "name": "is_inspector_choice_allowed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_pdf_pipeline_enabled": { + "name": "is_pdf_pipeline_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_team_mode_default": { + "name": "is_team_mode_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_apprentice_review_required": { + "name": "is_apprentice_review_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_guest_invites_enabled": { + "name": "is_guest_invites_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "require_defect_fields": { + "name": "require_defect_fields", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "agreement_retention_years": { + "name": "agreement_retention_years", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 6 + }, + "reinspection_statuses": { + "name": "reinspection_statuses", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_collab_editing_enabled": { + "name": "is_collab_editing_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sms_byo_provider": { + "name": "sms_byo_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_byo_provider": { + "name": "email_byo_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'resend'" + }, + "is_managed_eligible": { + "name": "is_managed_eligible", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "managed_provider": { + "name": "managed_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'twilio'" + }, + "is_reserve_schedule_enabled": { + "name": "is_reserve_schedule_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "reserve_term_years": { + "name": "reserve_term_years", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 12 + }, + "inflation_rate_bps": { + "name": "inflation_rate_bps", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_timezone": { + "name": "default_timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'UTC'" + }, + "booking_slot_mode": { + "name": "booking_slot_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'fixed'" + }, + "booking_slot_interval_min": { + "name": "booking_slot_interval_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "holiday_region": { + "name": "holiday_region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "holiday_public_policy": { + "name": "holiday_public_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "holiday_internal_policy": { + "name": "holiday_internal_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'advisory'" + }, + "default_locale": { + "name": "default_locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en-US'" + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "is_archive_revoking_access": { + "name": "is_archive_revoking_access", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "legal_mode": { + "name": "legal_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'hosted'" + }, + "custom_privacy_url": { + "name": "custom_privacy_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_terms_url": { + "name": "custom_terms_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "privacy_body": { + "name": "privacy_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terms_body": { + "name": "terms_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_configs_tenant_id_tenants_id_fk": { + "name": "tenant_configs_tenant_id_tenants_id_fk", + "tableFrom": "tenant_configs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenants": { + "name": "tenants", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'free'" + }, + "stripe_connect_account_id": { + "name": "stripe_connect_account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "max_users": { + "name": "max_users", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 5 + }, + "deployment_mode": { + "name": "deployment_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'shared'" + }, + "applied_cmd_seq": { + "name": "applied_cmd_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "applied_cred_seq": { + "name": "applied_cred_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tenants_slug_unique": { + "name": "tenants_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_invites": { + "name": "tenant_invites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'inspector'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mentor_id": { + "name": "mentor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assigned_section_ids": { + "name": "assigned_section_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "permission_overrides": { + "name": "permission_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_invites_tenant": { + "name": "idx_invites_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_tenant_invites_pending_email": { + "name": "uq_tenant_invites_pending_email", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": true, + "where": "status = 'pending'" + } + }, + "foreignKeys": { + "tenant_invites_tenant_id_tenants_id_fk": { + "name": "tenant_invites_tenant_id_tenants_id_fk", + "tableFrom": "tenant_invites", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "license_number": { + "name": "license_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_signature_base64": { + "name": "default_signature_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_signature_enabled": { + "name": "is_signature_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manager'" + }, + "onboarding_state": { + "name": "onboarding_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_totp_enabled": { + "name": "is_totp_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "totp_recovery_codes": { + "name": "totp_recovery_codes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totp_verified_at": { + "name": "totp_verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mentor_id": { + "name": "mentor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assigned_section_ids": { + "name": "assigned_section_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terms_accepted": { + "name": "terms_accepted", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permission_overrides": { + "name": "permission_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + "deleted_at" + ], + "isUnique": false + }, + "uq_users_tenant_email": { + "name": "uq_users_tenant_email", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": true, + "where": "deleted_at IS NULL" + }, + "idx_users_tenant": { + "name": "idx_users_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_users_slug_per_tenant": { + "name": "idx_users_slug_per_tenant", + "columns": [ + "tenant_id", + "slug" + ], + "isUnique": true + }, + "idx_users_email": { + "name": "idx_users_email", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "users_tenant_id_tenants_id_fk": { + "name": "users_tenant_id_tenants_id_fk", + "tableFrom": "users", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_slug": { + "name": "inspector_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_audit_tenant_created": { + "name": "idx_audit_tenant_created", + "columns": [ + "tenant_id", + "created_at" + ], + "isUnique": false + }, + "idx_audit_entity": { + "name": "idx_audit_entity", + "columns": [ + "entity_type", + "entity_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_logs_tenant_id_tenants_id_fk": { + "name": "audit_logs_tenant_id_tenants_id_fk", + "tableFrom": "audit_logs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration_test_results": { + "name": "integration_test_results", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_ok": { + "name": "is_ok", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tested_by_user_id": { + "name": "tested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tested_at": { + "name": "tested_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_integration_test_tenant_target": { + "name": "idx_integration_test_tenant_target", + "columns": [ + "tenant_id", + "target", + "tested_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_notifications_tenant_user_created": { + "name": "idx_notifications_tenant_user_created", + "columns": [ + "tenant_id", + "user_id", + "created_at" + ], + "isUnique": false + }, + "idx_notifications_tenant_user_unread": { + "name": "idx_notifications_tenant_user_unread", + "columns": [ + "tenant_id", + "user_id", + "read_at" + ], + "isUnique": false + }, + "idx_notifications_tenant_contact_created": { + "name": "idx_notifications_tenant_contact_created", + "columns": [ + "tenant_id", + "contact_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notifications_tenant_id_tenants_id_fk": { + "name": "notifications_tenant_id_tenants_id_fk", + "tableFrom": "notifications", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "parked_cmd_events": { + "name": "parked_cmd_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "envelope": { + "name": "envelope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_parked_cmd_events_received_at": { + "name": "idx_parked_cmd_events_received_at", + "columns": [ + "received_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "processed_cmd_events": { + "name": "processed_cmd_events", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cmd_type": { + "name": "cmd_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "processed_at": { + "name": "processed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "slug_reservations": { + "name": "slug_reservations", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_outbox": { + "name": "sync_outbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_tried_at": { + "name": "last_tried_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_sync_outbox_status_created": { + "name": "idx_sync_outbox_status_created", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_destruction_records": { + "name": "tenant_destruction_records", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_slug": { + "name": "tenant_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rows_deleted": { + "name": "rows_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "r2_objects": { + "name": "r2_objects", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "r2_bytes": { + "name": "r2_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "kv_keys": { + "name": "kv_keys", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "destroyed_at": { + "name": "destroyed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_destruction_tenant": { + "name": "idx_destruction_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_destruction_destroyed_at": { + "name": "idx_destruction_destroyed_at", + "columns": [ + "destroyed_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "client_uploads": { + "name": "client_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_kind": { + "name": "uploaded_by_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_ref": { + "name": "uploaded_by_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_name": { + "name": "uploaded_by_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_client_uploads_inspection": { + "name": "idx_client_uploads_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "uq_discount_codes_code_tenant": { + "columns": { + "upper(code)": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index d10be30f6..2f9ba38c7 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1785479812967, "tag": "0019_motionless_dark_phoenix", "breakpoints": true + }, + { + "idx": 20, + "version": "6", + "when": 1785502662839, + "tag": "0020_fancy_toxin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/api/agent/notification-preferences.ts b/server/api/agent/notification-preferences.ts index 6b8959e59..0ece07551 100644 --- a/server/api/agent/notification-preferences.ts +++ b/server/api/agent/notification-preferences.ts @@ -178,7 +178,7 @@ const agentNotificationPreferenceRoutes = createApiRouter() // Consent is per COMPANY, like everything else on this screen: // it attaches to the contact row that company holds. smsConsent: selected - ? await readSmsConsent(db, selected.tenantId, 'agent', [selected.contactId], disclosure) + ? await readSmsConsent(db, selected.tenantId, 'agent', [{ kind: 'contact' as const, id: selected.contactId }], disclosure) : null, }, }, 200); @@ -232,7 +232,7 @@ const agentNotificationPreferenceRoutes = createApiRouter() // A whole-channel stop is also a consent act on SMS (§4.2), and an // agent's revocation is recorded AS an agent's. if (action === 'disable' && channel === 'sms' && !classId) { - const block = await readSmsConsent(db, t.tenantId, 'agent', [t.contactId], null); + const block = await readSmsConsent(db, t.tenantId, 'agent', [{ kind: 'contact' as const, id: t.contactId }], null); await revokeChannel(new SmsConsentService(c.env.DB), t.tenantId, 'sms', block, 'agent'); } } diff --git a/server/api/notification-preferences.ts b/server/api/notification-preferences.ts index c524e5a40..c1aaeb61d 100644 --- a/server/api/notification-preferences.ts +++ b/server/api/notification-preferences.ts @@ -4,6 +4,8 @@ import { withMcpMetadata } from '../lib/route-metadata-standards'; import { getDrizzle } from '../lib/route-helpers'; import { buildScreenModel } from '../lib/notifications/screen-model'; import { applyBulk, assertChoosable, readChoices, writeChoice } from '../lib/notifications/preference-write'; +import { readSmsConsent, revokeChannel } from '../lib/notifications/channel-consent'; +import { SmsConsentService } from '../services/sms-consent.service'; /** * The signed-in reader's own notification preferences (spec §4). @@ -39,8 +41,14 @@ const ScreenResponseSchema = z.object({ email: z.string(), sms: z.string(), in_app: z.string(), }), })), - /** Always null for staff — see the GET handler. */ - smsConsent: z.null(), + smsConsent: z.object({ + phone: z.string().nullable(), + state: z.enum(['granted', 'implied', 'revoked', 'none']), + at: z.string().nullable(), + capturedVia: z.enum(['booking_form', 'optin_link', 'admin', 'settings_page']).nullable(), + subjects: z.array(z.object({ kind: z.enum(['contact', 'user']), id: z.string() })), + disclosure: z.object({ version: z.number(), text: z.string() }).nullable(), + }).nullable(), }), }).openapi('NotificationPreferencesScreen'); @@ -124,13 +132,15 @@ const notificationPreferenceRoutes = createApiRouter() // small — a row that restates the default would make the table grow // with the user base instead of with the decisions (§3.2). const chosen = await readChoices(db, tenantId, 'user', userId); - // No SMS consent block for staff: consent attaches to a `contacts` row - // and a staff member is a `users` row, and no user-facing class is both - // staff-addressed and SMS. Rendering an empty block would be a control - // over nothing (§4.2). + // A staff member IS a valid consent subject now — a `users` row, with + // `contact_id` left null. They are never granted (implied, like an + // agent); the block exists so their STOP has somewhere to land. + const smsConsent = await readSmsConsent( + db, tenantId, 'staff', [{ kind: 'user', id: userId }], null, + ); return c.json({ success: true as const, - data: { ...buildScreenModel('staff', chosen), smsConsent: null }, + data: { ...buildScreenModel('staff', chosen), smsConsent }, }, 200); }) .openapi(saveRoute, async (c) => { @@ -152,9 +162,16 @@ const notificationPreferenceRoutes = createApiRouter() const tenantId = c.get('tenantId') as string; const userId = c.get('user')?.sub as string; const change = c.req.valid('json'); + const db = getDrizzle(c); const stored = await applyBulk( - getDrizzle(c), { tenantId, subjectKind: 'user', subjectId: userId }, 'staff', change, + db, { tenantId, subjectKind: 'user', subjectId: userId }, 'staff', change, ); + // Stopping the whole text channel is a consent act here too — recorded + // as `staff` basis, so it never reads as consumer evidence. + if (change.action === 'disable' && change.channel === 'sms' && !change.classId) { + const block = await readSmsConsent(db, tenantId, 'staff', [{ kind: 'user', id: userId }], null); + await revokeChannel(new SmsConsentService(c.env.DB), tenantId, 'sms', block, 'staff'); + } return c.json({ success: true as const, stored }, 200); }); diff --git a/server/api/portal/notification-preferences.ts b/server/api/portal/notification-preferences.ts index 9e28c7516..5e8f8d61a 100644 --- a/server/api/portal/notification-preferences.ts +++ b/server/api/portal/notification-preferences.ts @@ -70,6 +70,9 @@ const BulkSchema = z.object({ classId: z.string().optional().describe('Limit to one notification (a row).'), }); +/** A client is only ever `contacts` rows — they have no account. */ +const asContacts = (ids: string[]) => ids.map((id) => ({ kind: 'contact' as const, id })); + function resolveTenantId(c: Context): string | null { return c.get('tenantId') || c.get('resolvedTenantId') || null; } @@ -196,7 +199,7 @@ const portalNotificationPreferenceRoutes = router } } const disclosure = await new SmsConsentService(c.env.DB).currentDisclosure(); - const smsConsent = await readSmsConsent(db, tenantId, 'client', contactIds, disclosure); + const smsConsent = await readSmsConsent(db, tenantId, 'client', asContacts(contactIds), disclosure); return c.json({ success: true as const, data: { ...buildScreenModel('client', chosen), smsConsent }, @@ -239,7 +242,7 @@ const portalNotificationPreferenceRoutes = router // Switching a whole channel off is also a CONSENT act on SMS, and the // ledger has to carry it wherever the reader stopped from (§4.2). if (change.action === 'disable' && change.channel === 'sms' && !change.classId) { - const block = await readSmsConsent(db, tenantId, 'client', contactIds, null); + const block = await readSmsConsent(db, tenantId, 'client', asContacts(contactIds), null); await revokeChannel(new SmsConsentService(c.env.DB), tenantId, 'sms', block, 'client'); } return c.json({ success: true as const }, 200); @@ -260,7 +263,7 @@ const portalNotificationPreferenceRoutes = router const db = getDrizzle(c); const contactIds = await contactIdsForEmail(db, tenantId, c.get('portalEmail') as string); - const block = await readSmsConsent(db, tenantId, 'client', contactIds, disclosure); + const block = await readSmsConsent(db, tenantId, 'client', asContacts(contactIds), disclosure); if (!block) throw Errors.BadRequest('There is nothing to change here.'); await grantSms(svc, tenantId, block, 'client', { diff --git a/server/lib/db/schema/compliance.ts b/server/lib/db/schema/compliance.ts index 96c6eb98a..3009b1194 100644 --- a/server/lib/db/schema/compliance.ts +++ b/server/lib/db/schema/compliance.ts @@ -63,8 +63,22 @@ export const smsDisclosureVersions = sqliteTable('sms_disclosure_versions', { export const smsConsentLog = sqliteTable('sms_consent_log', { id: text('id').primaryKey(), tenantId: text('tenant_id').notNull(), - contactId: text('contact_id').notNull(), // the contact the consent attaches to - recipientType: text('recipient_type', { enum: ['client', 'agent', 'other'] }).notNull(), + /** + * The contact this consent attaches to — NULL when the subject is a staff + * `users` row (see `subjectKind` below). Kept alongside the subject pair + * rather than retired because `idx_sms_consent_contact` and every existing + * reader use it, and a consent ledger is the wrong place to do a rename. + */ + contactId: text('contact_id'), + /** + * The BASIS the recipient was reachable under, for a carrier audit. + * + * `staff` is internal-operational: an employee under account/employment + * terms, never consumer consent. It is a separate value precisely so a + * staff STOP can be recorded without polluting the consumer evidence the + * ISV filing rests on — see docs/superpowers/specs/2026-07-30-sms-consent-isv-strategy.md. + */ + recipientType: text('recipient_type', { enum: ['client', 'agent', 'other', 'staff'] }).notNull(), action: text('action', { enum: ['granted', 'revoked'] }).notNull(), disclosureVersion: integer('disclosure_version').notNull(), // `settings_page` is a grant made inline on the notifications screen, with @@ -74,8 +88,22 @@ export const smsConsentLog = sqliteTable('sms_consent_log', { ip: text('ip'), userAgent: text('user_agent'), createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), + /** + * WHO the consent is about, generalised beyond `contacts`. + * + * Staff are `users` rows and have no contact, so a ledger keyed only on + * `contact_id` could not record their STOP at all. Mirrors the + * `notification_preferences` subject pair deliberately: one shape for "a + * person, of either kind", rather than a second XOR of nullable columns. + * + * Appended at the END — a column inserted mid-table makes drizzle-kit + * rebuild the whole thing, and this one holds legal evidence. + */ + subjectKind: text('subject_kind', { enum: ['contact', 'user'] }).notNull().default('contact'), + subjectId: text('subject_id').notNull().default(''), }, (t) => [ index('idx_sms_consent_contact').on(t.tenantId, t.contactId, t.createdAt), + index('idx_sms_consent_subject').on(t.tenantId, t.subjectKind, t.subjectId, t.createdAt), ]); // SMS provider compliance state — one row per tenant, tracks Twilio (or diff --git a/server/lib/notifications/channel-consent.ts b/server/lib/notifications/channel-consent.ts index 7c2897dfb..94a58fa9a 100644 --- a/server/lib/notifications/channel-consent.ts +++ b/server/lib/notifications/channel-consent.ts @@ -1,5 +1,8 @@ -import { and, desc, eq, inArray } from 'drizzle-orm'; -import { contacts, smsConsentLog } from '../db/schema'; +import { and, desc, eq, inArray, sql } from 'drizzle-orm'; +import { contacts, smsConsentLog, users } from '../db/schema'; + +/** The BASIS a reader is reachable under — what the ledger's audit column means. */ +const basisFor = (a: Audience) => (a === 'client' ? 'client' as const : a === 'agent' ? 'agent' as const : 'staff' as const); import type { Audience } from './classes'; /** @@ -28,8 +31,11 @@ export interface SmsConsentBlock { at: string | null; /** How it was captured — booking form, opt-in link, or by an admin. */ capturedVia: 'booking_form' | 'optin_link' | 'admin' | null; - /** The contact rows this reader is, so a Stop knows what to write. */ - contactIds: string[]; + /** + * WHO a Stop writes against. A client or agent resolves to `contacts` rows; + * a staff member is a single `users` row and has no contact at all. + */ + subjects: Array<{ kind: 'contact' | 'user'; id: string }>; /** * The disclosure the reader must SEE before granting, and its version. * @@ -43,28 +49,36 @@ export interface SmsConsentBlock { /** * Read the SMS consent block for one reader. * - * @returns `null` when the block must NOT render — which today is every staff - * reader. Consent attaches to a `contacts` row and a staff member is a - * `users` row; there is also no user-facing notification class that is - * both staff-addressed and SMS, so there is nothing to revoke. - * Inventing a staff consent row to make the screen look uniform would - * be a control over nothing, which is worse than no control (§4.2). + * STAFF ARE SUPPORTED, and the ledger says so honestly rather than uniformly. + * A staff subject is a `users` row with `contact_id` left NULL and + * `recipient_type: 'staff'` — internal-operational under account/employment + * terms, never consumer consent. They are never GRANTED here (they are implied, + * like agents); the only staff row that is ever written is a revocation, + * because a STOP binds whatever basis the first message was sent under. + * + * @returns `null` only when there is no subject at all to consent for. */ export async function readSmsConsent( // eslint-disable-next-line @typescript-eslint/no-explicit-any db: any, tenantId: string, audience: Audience, - contactIds: string[], + subjects: Array<{ kind: 'contact' | 'user'; id: string }>, disclosure: { version: number; text: string } | null, ): Promise { - if (contactIds.length === 0) return null; + if (subjects.length === 0) return null; + + const contactIds = subjects.filter((s) => s.kind === 'contact').map((s) => s.id); + const userIds = subjects.filter((s) => s.kind === 'user').map((s) => s.id); - const rows = await db.select({ phone: contacts.phone }) - .from(contacts) - .where(and(eq(contacts.tenantId, tenantId), inArray(contacts.id, contactIds))) - .all(); - const phone = rows.map((r: { phone: string | null }) => r.phone).find(Boolean) ?? null; + const phoneRows = contactIds.length + ? await db.select({ phone: contacts.phone }).from(contacts) + .where(and(eq(contacts.tenantId, tenantId), inArray(contacts.id, contactIds))).all() + : userIds.length + ? await db.select({ phone: users.phone }).from(users) + .where(inArray(users.id, userIds)).all() + : []; + const phone = phoneRows.map((r: { phone: string | null }) => r.phone).find(Boolean) ?? null; // The LATEST row across every identity this reader holds. Revocation binds // regardless of which contact row carried the original grant — the same @@ -77,29 +91,32 @@ export async function readSmsConsent( }).from(smsConsentLog) .where(and( eq(smsConsentLog.tenantId, tenantId), - inArray(smsConsentLog.contactId, contactIds), + inArray(smsConsentLog.subjectId, subjects.map((s) => s.id)), )) - .orderBy(desc(smsConsentLog.createdAt)).limit(1).get(); + // Insertion order breaks a same-millisecond tie: a STOP and a START recorded + // in the same millisecond otherwise resolve arbitrarily, and for a consent + // ledger "which one is latest" must never be a coin toss. + .orderBy(desc(smsConsentLog.createdAt), desc(sql`rowid`)).limit(1).get(); if (latest?.action === 'revoked') { return { phone, state: 'revoked', - at: toIso(latest.createdAt), capturedVia: latest.capturedVia ?? null, contactIds, disclosure, + at: toIso(latest.createdAt), capturedVia: latest.capturedVia ?? null, subjects, disclosure, }; } if (latest?.action === 'granted') { return { phone, state: 'granted', - at: toIso(latest.createdAt), capturedVia: latest.capturedVia ?? null, contactIds, disclosure, + at: toIso(latest.createdAt), capturedVia: latest.capturedVia ?? null, subjects, disclosure, }; } // Nothing on file. What that MEANS depends on who is asking: a business - // counterparty is reachable under an existing relationship, a consumer is - // not reachable at all until they say so. + // counterparty and a staff member are reachable under an existing + // relationship, a consumer is not reachable at all until they say so. return { - phone, state: audience === 'agent' ? 'implied' : 'none', - at: null, capturedVia: null, contactIds, disclosure, + phone, state: audience === 'client' ? 'none' : 'implied', + at: null, capturedVia: null, subjects, disclosure, }; } @@ -131,7 +148,11 @@ export interface ConsentRecorder { record( tenantId: string, contactId: string, action: 'granted' | 'revoked', capturedVia: 'booking_form' | 'optin_link' | 'admin' | 'settings_page', - meta: { ip?: string | undefined; userAgent?: string | undefined; recipientType?: 'client' | 'agent' | 'other' }, + meta: { + ip?: string | undefined; userAgent?: string | undefined; + recipientType?: 'client' | 'agent' | 'other' | 'staff'; + subjectKind?: 'contact' | 'user'; + }, ): Promise; } @@ -150,9 +171,14 @@ export async function grantSms( audience: Audience, meta: { ip?: string | undefined; userAgent?: string | undefined }, ): Promise { - const recipientType = audience === 'agent' ? 'agent' as const : 'client' as const; - for (const contactId of block.contactIds) { - await recorder.record(tenantId, contactId, 'granted', 'settings_page', { ...meta, recipientType }); + // Only consumers are ever GRANTED here. Agents and staff are implied, and + // writing a grant for them would put non-consumer messaging inside the + // consumer consent evidence (docs/sms-compliance.md). + if (audience !== 'client') return; + for (const s of block.subjects) { + await recorder.record(tenantId, s.id, 'granted', 'settings_page', { + ...meta, recipientType: 'client', subjectKind: s.kind, + }); } } @@ -168,8 +194,9 @@ export async function revokeChannel( // ledger says which basis the person was reachable under, and stamping // everyone 'client' would make the evidence wrong in the one direction that // matters to a carrier audit. - const recipientType = audience === 'agent' ? 'agent' as const : 'client' as const; - for (const contactId of block.contactIds) { - await recorder.record(tenantId, contactId, 'revoked', 'optin_link', { recipientType }); + for (const s of block.subjects) { + await recorder.record(tenantId, s.id, 'revoked', 'optin_link', { + recipientType: basisFor(audience), subjectKind: s.kind, + }); } } diff --git a/server/lib/sms/consent-basis.ts b/server/lib/sms/consent-basis.ts index 595b7c5ad..1efd90911 100644 --- a/server/lib/sms/consent-basis.ts +++ b/server/lib/sms/consent-basis.ts @@ -8,17 +8,22 @@ * any tenant-invented client role), and `other` is the bucket for Attorney / * Transaction Coordinator / Insurance Agent / Title Company. * - * `sms_consent_log.recipient_type` mirrors these values so a future capture - * path can stamp non-client rows honestly. Today only consumer capture paths - * write the ledger (booking form / opt-in link / admin attest); agent/other - * remain implied and are not recorded. Staff must not be written here as - * consumer consent. Do not unify agent/staff onto the client express UI - * solely for carrier filings — describe the layered program in TFV/campaign - * answers instead (see docs/sms-compliance.md). + * `sms_consent_log.recipient_type` mirrors these values so a capture path can + * stamp non-client rows honestly. Consumer capture paths (booking form / + * opt-in link / settings page / admin attest) record express grants; + * agent/other/staff remain IMPLIED and no grant is ever recorded for them. + * + * A staff row may now be written, but ONLY as a revocation — a STOP is a + * request to be left alone and it binds whatever the basis was. Never write a + * staff `granted` row: that would put internal operational messaging inside + * the consumer consent evidence, which is the pollution the layered program + * exists to avoid. Do not unify agent/staff onto the client express UI solely + * for carrier filings — describe the layers in TFV/campaign answers instead + * (see docs/sms-compliance.md). */ import type { RoleKind } from '../people/role-kinds'; -export type ConsentRecipientType = 'client' | 'agent' | 'other'; +export type ConsentRecipientType = 'client' | 'agent' | 'other' | 'staff'; export type ConsentBasis = 'express' | 'implied'; export const CONSENT_BASIS_BY_KIND: Record, tenantId: string, - contactId: string, + subjectId: string, ): Promise<'granted' | 'revoked' | null> { + // Keyed on `subject_id`, not `contact_id`, so a STAFF revocation (a `users` + // subject, whose `contact_id` is null) is honoured by the same lookup. The + // two agree for every contact row — the backfill set subject_id from + // contact_id — so this widens the gate without changing any existing answer. const row = await db.select({ action: smsConsentLog.action }).from(smsConsentLog) - .where(and(eq(smsConsentLog.tenantId, tenantId), eq(smsConsentLog.contactId, contactId))) - .orderBy(desc(smsConsentLog.createdAt)).limit(1).get(); + .where(and(eq(smsConsentLog.tenantId, tenantId), eq(smsConsentLog.subjectId, subjectId))) + // Insertion order breaks a same-millisecond tie: a STOP and a START recorded + // in the same millisecond otherwise resolve arbitrarily, and for a consent + // ledger "which one is latest" must never be a coin toss. + .orderBy(desc(smsConsentLog.createdAt), desc(sql`rowid`)).limit(1).get(); return (row?.action as 'granted' | 'revoked' | undefined) ?? null; } diff --git a/server/services/sms-consent.service.ts b/server/services/sms-consent.service.ts index 8c4ca5b12..cc727be20 100644 --- a/server/services/sms-consent.service.ts +++ b/server/services/sms-consent.service.ts @@ -1,5 +1,5 @@ import { drizzle } from 'drizzle-orm/d1'; -import { and, eq, desc, max } from 'drizzle-orm'; +import { and, eq, desc, max, sql } from 'drizzle-orm'; import { smsConsentLog, smsDisclosureVersions } from '../lib/db/schema'; import { nanoid } from 'nanoid'; @@ -28,19 +28,34 @@ export class SmsConsentService { /** * Append a consent event, stamping the current disclosure version. - * Capture paths today are consumer-only; `recipientType` defaults to - * `'client'`. A3.2 widened the column so a future agent/other capture can - * pass the matching type from `CONSENT_BASIS_BY_KIND` without another - * migration. + * + * The SUBJECT is a contact by default, because every capture path that + * existed before staff STOP was a consumer one. A staff subject passes + * `subjectKind: 'user'`, which leaves `contact_id` NULL — a staff member is + * a `users` row and has no contact to attach consent to. + * + * `recipientType` records the BASIS, not the subject kind: `staff` means + * internal-operational under account/employment terms, never consumer + * consent, so a staff STOP can be honoured without its row being read as + * evidence in a consumer filing. */ async record( - tenantId: string, contactId: string, action: ConsentAction, capturedVia: CapturedVia, - meta: { ip?: string | undefined; userAgent?: string | undefined; recipientType?: import('../lib/sms/consent-basis').ConsentRecipientType }, + tenantId: string, subjectId: string, action: ConsentAction, capturedVia: CapturedVia, + meta: { + ip?: string | undefined; userAgent?: string | undefined; + recipientType?: import('../lib/sms/consent-basis').ConsentRecipientType; + subjectKind?: 'contact' | 'user'; + }, ) { const db = this.getDrizzle(); const disc = await this.currentDisclosure(); + const subjectKind = meta.subjectKind ?? ('contact' as const); const row = { - id: nanoid(), tenantId, contactId, + id: nanoid(), tenantId, + // NULL for a user subject: there is no contact, and writing the + // user id here would make the column lie about what it holds. + contactId: subjectKind === 'contact' ? subjectId : null, + subjectKind, subjectId, recipientType: meta.recipientType ?? ('client' as const), action, disclosureVersion: disc?.version ?? 0, capturedVia, ip: meta.ip ?? null, userAgent: meta.userAgent ?? null, createdAt: new Date(), @@ -49,12 +64,21 @@ export class SmsConsentService { return row; } - /** Latest event for (tenant, contact), or null if none. */ - async getLatest(tenantId: string, contactId: string): Promise { + /** Latest event for one subject, or null if none. */ + async getLatest( + tenantId: string, subjectId: string, subjectKind: 'contact' | 'user' = 'contact', + ): Promise { const db = this.getDrizzle(); const row = await db.select({ action: smsConsentLog.action }).from(smsConsentLog) - .where(and(eq(smsConsentLog.tenantId, tenantId), eq(smsConsentLog.contactId, contactId))) - .orderBy(desc(smsConsentLog.createdAt)).limit(1).get(); + .where(and( + eq(smsConsentLog.tenantId, tenantId), + eq(smsConsentLog.subjectKind, subjectKind), + eq(smsConsentLog.subjectId, subjectId), + )) + // Insertion order breaks a same-millisecond tie: a STOP and a START recorded + // in the same millisecond otherwise resolve arbitrarily, and for a consent + // ledger "which one is latest" must never be a coin toss. + .orderBy(desc(smsConsentLog.createdAt), desc(sql`rowid`)).limit(1).get(); return (row?.action as ConsentAction) ?? null; } } diff --git a/tests/unit/messaging/message-template-sms-test-send.spec.ts b/tests/unit/messaging/message-template-sms-test-send.spec.ts index cedb64307..5ba0182d3 100644 --- a/tests/unit/messaging/message-template-sms-test-send.spec.ts +++ b/tests/unit/messaging/message-template-sms-test-send.spec.ts @@ -143,6 +143,8 @@ describe('POST /api/message-templates/test-send (SMS) — STOP revocation', () = } as never); await db.insert(schema.smsConsentLog).values({ id: 'sc-1', tenantId: TENANT, contactId: 'c-stop', recipientType: 'client', + // The gate reads the SUBJECT pair, not `contact_id`. + subjectKind: 'contact', subjectId: 'c-stop', action: 'revoked', disclosureVersion: 1, capturedVia: 'admin', createdAt: new Date(), } as never); } @@ -164,6 +166,8 @@ describe('POST /api/message-templates/test-send (SMS) — STOP revocation', () = await seedRevoked('+15559991234'); await db.insert(schema.smsConsentLog).values({ id: 'sc-2', tenantId: TENANT, contactId: 'c-stop', recipientType: 'client', + // The gate reads the SUBJECT pair, not `contact_id`. + subjectKind: 'contact', subjectId: 'c-stop', action: 'granted', disclosureVersion: 1, capturedVia: 'admin', createdAt: new Date(Date.now() + 1000), } as never); diff --git a/tests/unit/messaging/sms-api.spec.ts b/tests/unit/messaging/sms-api.spec.ts index 25c32adfa..8868335cc 100644 --- a/tests/unit/messaging/sms-api.spec.ts +++ b/tests/unit/messaging/sms-api.spec.ts @@ -1566,6 +1566,9 @@ describe('POST /sms/test — STOP revocation', () => { } as never); await db.insert(schema.smsConsentLog).values({ id: 'sc-stop', tenantId: TENANT, contactId: 'c-stop', recipientType: 'client', + // The gate reads the SUBJECT pair, not `contact_id` — that is what + // lets a staff `users` subject record a STOP at all. + subjectKind: 'contact', subjectId: 'c-stop', action: 'revoked', disclosureVersion: 1, capturedVia: 'admin', createdAt: new Date(), } as never); const sendMessage = stubProvider(); diff --git a/tests/unit/messaging/sms-send-gate.spec.ts b/tests/unit/messaging/sms-send-gate.spec.ts index e3f95a12c..402c9b26b 100644 --- a/tests/unit/messaging/sms-send-gate.spec.ts +++ b/tests/unit/messaging/sms-send-gate.spec.ts @@ -41,10 +41,24 @@ async function seedContact(id: string, phone: string | null) { } async function seedConsent(id: string, contactId: string, action: 'granted' | 'revoked', at = new Date()) { await db.insert(schema.smsConsentLog).values({ - id, tenantId: TENANT, contactId, recipientType: 'client', + id, tenantId: TENANT, contactId, + // The subject pair is what the gate reads — `contact_id` alone no + // longer answers, because a staff subject has none. + subjectKind: 'contact', subjectId: contactId, + recipientType: 'client', action, disclosureVersion: 1, capturedVia: 'admin', createdAt: at, } as never); } + +/** A staff STOP: a `users` subject, with no contact at all. */ +async function seedStaffRevocation(id: string, userId: string) { + await db.insert(schema.smsConsentLog).values({ + id, tenantId: TENANT, contactId: null, + subjectKind: 'user', subjectId: userId, + recipientType: 'staff', action: 'revoked', + disclosureVersion: 1, capturedVia: 'optin_link', createdAt: new Date(), + } as never); +} const gate = (over: Partial[0]> = {}) => // eslint-disable-next-line @typescript-eslint/no-explicit-any smsSendGate({ db: db as any, tenantId: TENANT, to: PHONE, purpose: 'notification', ...over }); @@ -227,3 +241,36 @@ describe('smsSendGate — the recipient’s own preference', () => { expect(r).toEqual({ allowed: false, reason: 'no sms consent' }); }); }); + +describe('smsSendGate — a staff STOP', () => { + /** + * Staff are a `users` row and have no contact, so before the subject pair + * existed the ledger could not record their STOP at all. They are never + * GRANTED here — internal messaging is implied under account terms — but a + * revocation binds whatever the basis was, which is the one CTIA rule that + * is universal. + */ + it('honours a revocation recorded against a users subject', async () => { + await seedStaffRevocation('sc-staff', 'u-staff'); + const r = await gate({ contactId: 'u-staff', roleKind: 'other' }); + expect(r.allowed).toBe(false); + }); + + it('still sends to a staff member who never stopped', async () => { + // Staff have no `RoleKind` at all — the vocabulary is client/agent/other, + // which is the same fact that makes them a `users` subject rather than a + // contact. They ride the implied path, like a business counterparty. + await seedContact('c-staff', PHONE); + const r = await gate({ contactId: 'c-staff', roleKind: 'other' }); + expect(r.allowed).toBe(true); + }); + + it('requires express consent for a kind it does not recognise', async () => { + // Fail CLOSED. This used to throw: indexing the basis map with an + // unknown value and reading `.basis` of undefined, which in a + // compliance gate is worse than either answer. + await seedContact('c-x', PHONE); + const r = await gate({ contactId: 'c-x', roleKind: 'not-a-kind' as never }); + expect(r).toEqual({ allowed: false, reason: 'no sms consent' }); + }); +}); diff --git a/tests/unit/notifications/channel-consent.spec.ts b/tests/unit/notifications/channel-consent.spec.ts new file mode 100644 index 000000000..2ccc5dd94 --- /dev/null +++ b/tests/unit/notifications/channel-consent.spec.ts @@ -0,0 +1,124 @@ +/** + * The consent ledger's subject, and the one row that must never be written. + * + * A staff member is a `users` row with no contact, so before the subject pair + * existed their STOP had nowhere to land — the ISV strategy promised + * "separate track + STOP" and the schema could not keep the second half. + * + * What did NOT change is the half that matters to a carrier: only consumers + * ever produce a `granted` row. "Show us your opt-in proof" must keep pointing + * at consumers alone, which is only true if agents and staff never enter the + * ledger as grants. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createTestDb, setupSchema } from '../db'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import * as schema from '../../../server/lib/db/schema'; + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); + +// eslint-disable-next-line import/order +import { grantSms, readSmsConsent, revokeChannel, type ConsentRecorder } from '../../../server/lib/notifications/channel-consent'; + +const TENANT = 't-consent'; +let db: BetterSQLite3Database; +let sqlite: { close: () => void }; + +/** Records what would have been written, so the assertions are about intent. */ +function recorder() { + const rows: Array> = []; + const rec: ConsentRecorder = { + async record(tenantId, subjectId, action, capturedVia, meta) { + rows.push({ tenantId, subjectId, action, capturedVia, ...meta }); + }, + }; + return { rec, rows }; +} + +beforeEach(async () => { + const fx = createTestDb(); + db = fx.db as BetterSQLite3Database; + sqlite = fx.sqlite; + await setupSchema(fx.sqlite); +}); +afterEach(() => sqlite.close()); + +const block = (subjects: Array<{ kind: 'contact' | 'user'; id: string }>) => + ({ phone: null, state: 'none' as const, at: null, capturedVia: null, subjects, disclosure: null }); + +describe('who may enter the consent ledger', () => { + it('records a STAFF stop against a users subject', async () => { + const { rec, rows } = recorder(); + await revokeChannel(rec, TENANT, 'sms', block([{ kind: 'user', id: 'u1' }]), 'staff'); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ action: 'revoked', subjectKind: 'user', recipientType: 'staff' }); + }); + + it('records an AGENT stop as an agent, not as a client', async () => { + // The basis column exists to say which relationship made the person + // reachable. Stamping everyone 'client' would make the evidence wrong + // in the one direction a carrier audit cares about. + const { rec, rows } = recorder(); + await revokeChannel(rec, TENANT, 'sms', block([{ kind: 'contact', id: 'c1' }]), 'agent'); + expect(rows[0]).toMatchObject({ recipientType: 'agent' }); + }); + + it('NEVER records a grant for staff', async () => { + const { rec, rows } = recorder(); + await grantSms(rec, TENANT, block([{ kind: 'user', id: 'u1' }]), 'staff', {}); + expect(rows).toEqual([]); + }); + + it('NEVER records a grant for an agent', async () => { + // Implied consent has nothing to grant. A `granted` row here would put + // B2B messaging inside the consumer evidence — the pollution the + // layered program exists to prevent. + const { rec, rows } = recorder(); + await grantSms(rec, TENANT, block([{ kind: 'contact', id: 'c1' }]), 'agent', {}); + expect(rows).toEqual([]); + }); + + it('DOES record a grant for a client, with the evidence fields', async () => { + const { rec, rows } = recorder(); + await grantSms(rec, TENANT, block([{ kind: 'contact', id: 'c1' }]), 'client', { + ip: '203.0.113.7', userAgent: 'Mozilla/5.0', + }); + expect(rows[0]).toMatchObject({ + action: 'granted', capturedVia: 'settings_page', + recipientType: 'client', ip: '203.0.113.7', userAgent: 'Mozilla/5.0', + }); + }); + + it('writes nothing at all for the email channel', async () => { + // Email has no consent artifact — only deliverability suppression, + // which is a different fact. Its "off" is the preference cascade alone. + const { rec, rows } = recorder(); + await revokeChannel(rec, TENANT, 'email', block([{ kind: 'contact', id: 'c1' }]), 'client'); + expect(rows).toEqual([]); + }); +}); + +describe('reading the block', () => { + it('finds a staff revocation stored against a users subject', async () => { + await db.insert(schema.smsConsentLog).values({ + id: 'sc1', tenantId: TENANT, contactId: null, + subjectKind: 'user', subjectId: 'u1', recipientType: 'staff', + action: 'revoked', disclosureVersion: 1, capturedVia: 'optin_link', createdAt: new Date(), + } as never); + + const b = await readSmsConsent(db, TENANT, 'staff', [{ kind: 'user', id: 'u1' }], null); + expect(b?.state).toBe('revoked'); + }); + + it('reads "nothing on file" as IMPLIED for staff and agents, but NONE for a client', async () => { + // Same absence, opposite meaning: a consumer is unreachable until they + // say so; everyone else is reachable until they say stop. + expect((await readSmsConsent(db, TENANT, 'staff', [{ kind: 'user', id: 'u2' }], null))?.state).toBe('implied'); + expect((await readSmsConsent(db, TENANT, 'agent', [{ kind: 'contact', id: 'c2' }], null))?.state).toBe('implied'); + expect((await readSmsConsent(db, TENANT, 'client', [{ kind: 'contact', id: 'c3' }], null))?.state).toBe('none'); + }); + + it('renders nothing when there is no subject at all', async () => { + expect(await readSmsConsent(db, TENANT, 'client', [], null)).toBeNull(); + }); +}); From 828dfc8fbffbb61a8f512c529ac19270a47f12eb Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 31 Jul 2026 22:32:34 +0800 Subject: [PATCH 22/48] =?UTF-8?q?feat(sms):=20staff=20and=20agents=20can?= =?UTF-8?q?=20turn=20texts=20back=20on=20=E2=80=94=20one=20surface,=20one?= =?UTF-8?q?=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps, both found by looking at the screen rather than at a test. THE ONE-WAY DOOR. Last commit gave staff a STOP and no way back: `grantSms` returned early for any non-client audience, on the reasoning that a staff `granted` row pollutes consumer evidence. That reasoning was too blunt. Staff and agents never granted anything — they are reachable under an existing relationship — so their "on" is a RESUME that withdraws the earlier stop, not a consent capture. It is recorded under `recipient_type: 'staff'` / `'agent'`, with no disclosure and nothing to acknowledge, because there was never anything to agree to. The invariant that actually protects the ISV filing is not "no staff rows" but "NO NON-CONSUMER ROW IS EVER LABELLED `client`" — a filing counting opt-in evidence filters on that column, which is the whole reason it is not a boolean. `channel-consent.spec.ts` now pins that directly. Both compliance documents were corrected rather than left saying the stricter thing the code no longer does. THE TRIPLICATION. Staff, agent and client each carried their own fetcher, status, toast, save/bulk handlers and — worst — their own copy of the rule that a revoked consent locks the Text column. Three copies of a rule is three chances for one surface to quietly stop enforcing it, and that one is what keeps the screen agreeing with the send gate. `` now owns all of it; the three wrappers are ~50 lines of chrome and an intent name. Falling out of that: - express-vs-implied comes from the SERVER as `smsConsent.mode`. Three call sites hand-setting it was three chances to ask a staff member to acknowledge a consumer disclosure they never needed. - all three routes expose the same `PUT …/sms-consent`, so no surface is the one that cannot resume. - `` is shared with the public `/sms-optin` page. Both stamp the same `disclosure_version` into the same ledger, so two copies of that markup could drift while the row still claimed the reader saw version N. It also fixed a real gap: the inline grant was missing the privacy and terms links the opt-in page has shown all along. Verified in the browser: staff STOP writes `subject_kind=user, contact_id=null, recipient_type=staff, revoked`, the Text column locks, and the resume writes `granted / settings_page` with a real user agent — the append-only ledger keeping both halves of the history. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RgBRZQhGELdkaWGorWwWKV --- .../notifications/NotificationSettings.tsx | 124 ++++++++++++++++++ .../notifications/SmsConsentBlock.test.tsx | 14 +- .../notifications/SmsConsentBlock.tsx | 35 +++-- .../notifications/SmsDisclosure.tsx | 44 +++++++ .../portal/hub/PortalNotificationSection.tsx | 91 +++---------- .../settings/NotificationPreferencesCard.tsx | 100 +++++--------- app/lib/settings-notifications.server.ts | 24 +++- app/routes/agent/settings-profile.tsx | 106 +++++---------- app/routes/public/sms-optin.tsx | 20 +-- app/routes/settings-profile.tsx | 7 +- docs/sms-compliance.md | 15 ++- messages/en/components.json | 1 + scripts/file-size-baseline.json | 2 +- server/api/agent/notification-preferences.ts | 61 ++++++++- server/api/notification-preferences.ts | 46 ++++++- server/lib/mcp/openapi-snapshot.json | 60 +++++++++ server/lib/notifications/channel-consent.ts | 43 ++++-- .../notifications/channel-consent.spec.ts | 25 +++- 18 files changed, 549 insertions(+), 269 deletions(-) create mode 100644 app/components/notifications/NotificationSettings.tsx create mode 100644 app/components/notifications/SmsDisclosure.tsx diff --git a/app/components/notifications/NotificationSettings.tsx b/app/components/notifications/NotificationSettings.tsx new file mode 100644 index 000000000..f21044578 --- /dev/null +++ b/app/components/notifications/NotificationSettings.tsx @@ -0,0 +1,124 @@ +import { useFetcher } from "react-router"; +import { + NotificationPreferences, + type AlwaysSentItem, + type ChannelId, + type ChoiceRow, +} from "~/components/notifications/NotificationPreferences"; +import { SmsConsentBlock, type SmsConsent } from "~/components/notifications/SmsConsentBlock"; +import { useNotificationSaveToast } from "~/hooks/useNotificationSaveToast"; +import { m } from "~/paraglide/messages"; + +/** + * The whole notifications surface: consent, the grid, and every rule that ties + * the two together. + * + * Staff, agent and client each had their own copy of this — the fetcher, the + * saving/saved status, the toast, the save and bulk handlers, and the two + * derived facts that matter most (`smsUnavailable`, and the locked column that + * follows from it). Three copies of a rule is three chances for one surface to + * quietly stop enforcing it, and the one at risk here is the one that keeps the + * screen agreeing with the send gate. + * + * WHAT DIFFERS BETWEEN SURFACES IS ONLY HOW A CHANGE IS SUBMITTED — the intent + * name each route action listens for, and the extra fields the agent needs to + * name a company. Both are props. Everything else is the same product. + */ + +export interface NotificationSettingsProps { + alwaysSent: AlwaysSentItem[]; + youChoose: ChoiceRow[]; + /** Null when this reader has no SMS identity to consent with. */ + smsConsent: SmsConsent | null; + /** The read failed — distinct from "you have nothing", which renders empty. */ + loadError?: string | null; + locale?: string; + /** The opt-in page, when this surface can link out to it. */ + manageHref?: string | undefined; + /** Intent names this surface's route action listens for. */ + intents: { save: string; bulk: string; grant?: string }; + /** Fields every submit carries — the agent's company scope. */ + extraFields?: Record; +} + +export function NotificationSettings({ + alwaysSent, youChoose, smsConsent, loadError = null, + locale = "en-US", manageHref, intents, extraFields = {}, +}: NotificationSettingsProps) { + const fetcher = useFetcher<{ + ok?: boolean; success?: boolean; error?: string; intent?: string; + }>(); + + const mine = fetcher.data + && [intents.save, intents.bulk, intents.grant].includes(fetcher.data.intent); + const result = mine ? fetcher.data : null; + // Two shapes in the wild: `{ok}` from the portal actions and `{success}` + // from the settings ones. Reading both here is what let the three copies + // disagree about which one counted as a failure. + const failed = !!result && (result.ok === false || result.success === false); + const saveError = failed ? (result.error ?? null) : null; + useNotificationSaveToast({ data: result, failed, error: saveError }); + + const busy = fetcher.state !== "idle"; + const status = busy ? ("saving" as const) : ("idle" as const); + + // No consent means no text can arrive, whatever a row says — so the column + // is DISABLED, not merely unchecked. One place, three surfaces. + const smsUnavailable = !!smsConsent + && (smsConsent.state === "revoked" || smsConsent.state === "none"); + + const submit = (fields: Record) => + fetcher.submit({ ...fields, ...extraFields }, { method: "post" }); + + return ( +
+ {smsConsent && !loadError && ( + // ABOVE the grid: consent is the gate, the grid is what happens + // behind it. Stopping is one request — the ledger entry and the + // Text-column cascade — so the two can never disagree. + submit({ + intent: intents.bulk, action: "disable", channel: "sms", + })} + {...(intents.grant + ? { + onGrant: (disclosureVersion: number) => submit({ + intent: intents.grant!, disclosureVersion: String(disclosureVersion), + }), + } + : {})} + /> + )} + + {loadError ? ( + // Never render the two counts when the read failed. "0 + // notifications you cannot switch off" is a confident false + // answer, and the count is the loudest thing on the card. +

{loadError}

+ ) : ( + submit({ + intent: intents.save, classId, channel, enabled: String(enabled), + })} + onBulk={(enabled, scope) => submit({ + intent: intents.bulk, + action: enabled ? "enable" : "disable", + ...(scope.channel ? { channel: scope.channel } : {}), + ...(scope.classId ? { classId: scope.classId } : {}), + })} + /> + )} +
+ ); +} + +export type { ChannelId }; diff --git a/app/components/notifications/SmsConsentBlock.test.tsx b/app/components/notifications/SmsConsentBlock.test.tsx index 8aa9573d6..579cb0bf6 100644 --- a/app/components/notifications/SmsConsentBlock.test.tsx +++ b/app/components/notifications/SmsConsentBlock.test.tsx @@ -13,7 +13,7 @@ import { SmsConsentBlock, type SmsConsent } from "./SmsConsentBlock"; const DISCLOSURE = { version: 3, text: "Message and data rates may apply." }; const base: SmsConsent = { phone: "+1 555 000 1111", state: "granted", at: "2026-06-12T00:00:00.000Z", - capturedVia: "booking_form", disclosure: DISCLOSURE, + capturedVia: "booking_form", disclosure: DISCLOSURE, mode: "express", }; function setup(consent: Partial = {}, manageHref?: string) { @@ -73,12 +73,22 @@ describe("SMS consent block", () => { expect(c.onGrant).toHaveBeenCalledWith(3); }); - it("offers no inline grant when there is no disclosure to show", () => { + it("offers no inline grant when an EXPRESS reader has no disclosure to show", () => { // No text means nothing the reader could have agreed to. const c = setup({ state: "revoked", disclosure: null }); expect(c.queryByText(/Turn texts on/i)).toBeNull(); }); + it("lets an IMPLIED reader resume with no disclosure and no acknowledgement", () => { + // Staff and agents never granted anything, so there is nothing to agree + // to. Refusing them the button was a one-way door: stopped, no way back. + const c = setup({ state: "revoked", disclosure: null, mode: "implied" }); + const button = c.getByText(/Turn texts back on/i).closest("button")!; + expect(button.disabled).toBe(false); + button.click(); + expect(c.onGrant).toHaveBeenCalled(); + }); + it("says an agent is reachable under the relationship, without claiming a grant", () => { // Implied consent has no grant date to show. Printing one would be // inventing evidence; saying nothing would look like a bug. diff --git a/app/components/notifications/SmsConsentBlock.tsx b/app/components/notifications/SmsConsentBlock.tsx index abc00f72f..ced5d1bb6 100644 --- a/app/components/notifications/SmsConsentBlock.tsx +++ b/app/components/notifications/SmsConsentBlock.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { Button, Checkbox } from "@core/shared-ui"; import { formatDate } from "~/lib/format"; +import { SmsDisclosure } from "~/components/notifications/SmsDisclosure"; import { m } from "~/paraglide/messages"; /** @@ -26,8 +27,18 @@ export interface SmsConsent { state: SmsConsentState; at: string | null; capturedVia: "booking_form" | "optin_link" | "admin" | "settings_page" | null; + /** + * `express` — a consumer granting consent: the disclosure must be on screen + * and acknowledged. `implied` — reachable under an existing relationship + * and never granted anything, so turning it back on is a RESUME with + * nothing to agree to. Comes from the server, which knows the audience. + */ + mode: "express" | "implied"; /** What the reader must SEE before granting, and the version recorded. */ disclosure: { version: number; text: string } | null; + /** The same two links the public opt-in page shows. */ + privacyUrl?: string | null; + termsUrl?: string | null; } const SOURCE = { @@ -39,6 +50,7 @@ const SOURCE = { export function SmsConsentBlock({ consent, manageHref, onStop, onGrant, busy = false, locale = "en-US", + }: { consent: SmsConsent; /** The opt-in page — where consent can be granted with its disclosure. */ @@ -67,7 +79,10 @@ export function SmsConsentBlock({ const day = (iso: string | null) => (iso ? formatDate(iso, { locale }) : ""); const on = consent.state === "granted" || consent.state === "implied"; const [ack, setAck] = useState(false); - const canGrant = !on && !!onGrant && !!consent.disclosure; + const implied = consent.mode === "implied"; + // An express grant needs the disclosure on screen; a resume does not, + // because there was never a disclosure to agree to. + const canGrant = !on && !!onGrant && (implied || !!consent.disclosure); return (
@@ -97,7 +112,7 @@ export function SmsConsentBlock({

{m.notif_prefs_sms_implied()}

)} - {canGrant && ( + {canGrant && !implied && ( // The disclosure is on screen BEFORE the acknowledgement, and // its version travels with the grant. That is what makes an // inline switch a record rather than a claim. @@ -106,9 +121,13 @@ export function SmsConsentBlock({ {m.notif_prefs_sms_disclosure_show()} -

- {consent.disclosure!.text} -

+
+ +
)} - {notifications.smsConsent && ( -
- bulkNotification(false, { channel: "sms" })} - busy={notifyFetcher.state !== "idle"} - /> -
- )} - {notifications.smsConsent && ( -
- bulkNotification(false, { channel: "sms" })} - busy={notifyFetcher.state !== "idle"} - /> -
- )}
-
diff --git a/app/routes/public/sms-optin.tsx b/app/routes/public/sms-optin.tsx index f8984bdb2..8eb602271 100644 --- a/app/routes/public/sms-optin.tsx +++ b/app/routes/public/sms-optin.tsx @@ -1,6 +1,7 @@ import { useLoaderData, useActionData, useNavigation, Form } from "react-router"; import type { Route } from "./+types/sms-optin"; import { createApi } from "~/lib/api-client.server"; +import { SmsDisclosure } from "~/components/notifications/SmsDisclosure"; import { m } from "~/paraglide/messages"; export function meta() { @@ -95,19 +96,12 @@ export default function SmsOptinPage() { {m.sms_optin_intro_1()}{" "} {data.companyName}{m.sms_optin_intro_2()}

-
-

{data.disclosureText}

- {(data.privacyUrl || data.termsUrl) && ( -

- {data.privacyUrl && ( - {m.sms_optin_privacy_link()} - )} - {data.privacyUrl && data.termsUrl && · } - {data.termsUrl && ( - {m.sms_optin_terms_link()} - )} -

- )} +
+
{actionData?.error && (

diff --git a/app/routes/settings-profile.tsx b/app/routes/settings-profile.tsx index 160ed5ee3..2f4aaa6ee 100644 --- a/app/routes/settings-profile.tsx +++ b/app/routes/settings-profile.tsx @@ -18,7 +18,7 @@ import { LOCALE_OPTIONS } from "~/lib/locales"; import { SectionNav } from "~/components/settings/SectionNav"; import { CredentialsEditor, type EditorCredential } from "~/components/settings/CredentialsEditor"; import { NotificationPreferencesCard } from "~/components/settings/NotificationPreferencesCard"; -import { bulkNotificationChoice, loadNotificationScreen, saveNotificationChoice } from "~/lib/settings-notifications.server"; +import { bulkNotificationChoice, grantNotificationSms, loadNotificationScreen, saveNotificationChoice } from "~/lib/settings-notifications.server"; import { m } from "~/paraglide/messages"; /* ------------------------------------------------------------------ */ @@ -73,6 +73,10 @@ export async function action({ request, context }: Route.ActionArgs) { return { ...(await bulkNotificationChoice(api, fd)), intent }; } + if (intent === "grant-notification-sms") { + return { ...(await grantNotificationSms(api, request)), intent }; + } + // Handle save-signature intent from the SignaturePad fetcher if (intent === "save-signature") { const signatureBase64 = fd.get("signatureBase64") as string | null; @@ -502,6 +506,7 @@ export default function SettingsProfilePage() { alwaysSent={notifications.alwaysSent} youChoose={notifications.youChoose} loadError={notifications.error} + smsConsent={notifications.smsConsent} />

diff --git a/docs/sms-compliance.md b/docs/sms-compliance.md index 093f224ea..206e27fb9 100644 --- a/docs/sms-compliance.md +++ b/docs/sms-compliance.md @@ -29,12 +29,15 @@ Those effective URLs appear in booking footers, the client portal, invoices, and | Agents / other parties on the job | Implied — phone on file for the transaction | Established business relationship; STOP still applies | | Staff | Account / employment terms | Internal; no recorded opt-in — STOP still applies | -**STOP works for everyone, opt-in evidence is consumers only.** Agents and staff -are never asked for express consent, so no `granted` row is ever recorded for -them. A STOP from any of them IS recorded and honoured, because a request to be -left alone binds whatever basis the first message was sent under. That keeps the -answer to "show us your opt-in proof" pointing at consumers alone, which is what -a carrier is asking about, while nobody who says stop keeps getting texts. +**STOP works for everyone; opt-in EVIDENCE is consumers only.** Agents and staff +are never shown a disclosure and never asked to agree to one — they are reachable +under an existing relationship. They can stop, and they can start again, and both +acts are recorded against their own `recipient_type` (`agent` / `staff`). + +When a carrier asks to see your opt-in proof, the answer filters on +`recipient_type = 'client'`. That is what keeps the layers separable inside one +ledger: a staff member turning texts back on never appears as a consumer who +opted in, and nobody who says stop keeps getting texts. --- diff --git a/messages/en/components.json b/messages/en/components.json index 0989ca979..6eca21f3f 100644 --- a/messages/en/components.json +++ b/messages/en/components.json @@ -168,6 +168,7 @@ "notif_prefs_sms_stop": "Stop texts", "notif_prefs_sms_grant_ack": "Yes, text me at this number.", "notif_prefs_sms_grant": "Turn texts on", + "notif_prefs_sms_resume": "Turn texts back on", "notif_prefs_sms_disclosure_show": "Read what you're agreeing to", "notif_prefs_sms_locked": "Text is switched off above, so these do not apply.", "notif_prefs_sms_manage": "Manage texts", diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 5a2718749..435976de0 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -35,8 +35,8 @@ "server/api/public-report.ts": 536, "server/services/inspection/inspection-photo.service.ts": 531, "app/components/NewInspectionWizard.tsx": 530, + "app/routes/settings-profile.tsx": 530, "server/api/inspections/media-studio.ts": 530, - "app/routes/settings-profile.tsx": 525, "server/services/portal-access.service.ts": 525, "server/api/inspections/publish.ts": 516, "server/api/portal.ts": 515, diff --git a/server/api/agent/notification-preferences.ts b/server/api/agent/notification-preferences.ts index 0ece07551..087e9208b 100644 --- a/server/api/agent/notification-preferences.ts +++ b/server/api/agent/notification-preferences.ts @@ -6,7 +6,7 @@ import { getDrizzle } from '../../lib/route-helpers'; import { buildScreenModel } from '../../lib/notifications/screen-model'; import { applyBulk, assertChoosable, readChoices, writeChoice } from '../../lib/notifications/preference-write'; import { listAgentCompanies } from '../../services/agent/companies'; -import { readSmsConsent, revokeChannel } from '../../lib/notifications/channel-consent'; +import { grantSms, readSmsConsent, revokeChannel } from '../../lib/notifications/channel-consent'; import { SmsConsentService } from '../../services/sms-consent.service'; import { Errors } from '../../lib/errors'; @@ -150,6 +150,43 @@ const saveRoute = createRoute(withMcpMetadata({ 'class default deletes the row rather than storing it.', }, { scopes: ['agent'], tier: 'extended' })); +const grantRoute = createRoute(withMcpMetadata({ + method: 'put', + path: '/notification-preferences/sms-consent', + tags: ['agents'], + summary: 'Turn text messages back on at one company', + request: { + body: { + content: { + 'application/json': { + schema: z.object({ + companyId: z.string().optional() + .describe('Tenant id to resume at. Required unless scope is "all".'), + scope: z.enum(['company', 'all']).optional() + .describe('"all" resumes at every company currently linked to this agent.'), + disclosureVersion: z.number().int().optional() + .describe('Ignored for an agent: implied consent has nothing to disclose.'), + }), + }, + }, + }, + }, + responses: { + 200: { + content: { 'application/json': { schema: z.object({ success: z.literal(true), applied: z.number() }) } }, + description: 'Recorded.', + }, + 400: { description: 'A company this agent is not bound to' }, + 401: { description: 'Unauthorized' }, + }, + security: [{ bearerAuth: [] }], + operationId: 'grantAgentSmsConsent', + description: + 'Withdraws an earlier stop at one company, or at every linked company. Agents are ' + + 'implied, so this records a resume under recipient_type agent — never consumer ' + + 'opt-in evidence.', +}, { scopes: ['agent'], tier: 'extended' })); + const agentNotificationPreferenceRoutes = createApiRouter() .openapi(getScreenRoute, async (c) => { await requireRole('agent')(c, async () => {}); @@ -237,6 +274,28 @@ const agentNotificationPreferenceRoutes = createApiRouter() } } return c.json({ success: true as const, applied: targets.length }, 200); + }) + .openapi(grantRoute, async (c) => { + await requireRole('agent')(c, async () => {}); + const agentUserId = c.get('user').sub; + const { companyId, scope } = c.req.valid('json'); + + const db = getDrizzle(c); + const companies = await listAgentCompanies(db, agentUserId); + const targets = scope === 'all' ? companies : companies.filter((x) => x.tenantId === companyId); + if (targets.length === 0) throw Errors.BadRequest('You are not currently linked to that company.'); + + const svc = new SmsConsentService(c.env.DB); + for (const t of targets) { + const block = await readSmsConsent(db, t.tenantId, 'agent', [{ kind: 'contact' as const, id: t.contactId }], null); + if (block) { + await grantSms(svc, t.tenantId, block, 'agent', { + ip: c.req.header('cf-connecting-ip'), + userAgent: c.req.header('user-agent'), + }); + } + } + return c.json({ success: true as const, applied: targets.length }, 200); }); export default agentNotificationPreferenceRoutes; diff --git a/server/api/notification-preferences.ts b/server/api/notification-preferences.ts index c1aaeb61d..e8f95d097 100644 --- a/server/api/notification-preferences.ts +++ b/server/api/notification-preferences.ts @@ -3,8 +3,9 @@ import { createApiRouter } from '../lib/openapi-router'; import { withMcpMetadata } from '../lib/route-metadata-standards'; import { getDrizzle } from '../lib/route-helpers'; import { buildScreenModel } from '../lib/notifications/screen-model'; +import { Errors } from '../lib/errors'; import { applyBulk, assertChoosable, readChoices, writeChoice } from '../lib/notifications/preference-write'; -import { readSmsConsent, revokeChannel } from '../lib/notifications/channel-consent'; +import { grantSms, readSmsConsent, revokeChannel } from '../lib/notifications/channel-consent'; import { SmsConsentService } from '../services/sms-consent.service'; /** @@ -46,6 +47,7 @@ const ScreenResponseSchema = z.object({ state: z.enum(['granted', 'implied', 'revoked', 'none']), at: z.string().nullable(), capturedVia: z.enum(['booking_form', 'optin_link', 'admin', 'settings_page']).nullable(), + mode: z.enum(['express', 'implied']), subjects: z.array(z.object({ kind: z.enum(['contact', 'user']), id: z.string() })), disclosure: z.object({ version: z.number(), text: z.string() }).nullable(), }).nullable(), @@ -122,6 +124,36 @@ const bulkRoute = createRoute(withMcpMetadata({ 'always-sent notifications are never touched.', }, { scopes: ['write'], tier: 'extended' })); +const grantRoute = createRoute(withMcpMetadata({ + method: 'put', + path: '/notification-preferences/sms-consent', + tags: ['notifications'], + summary: 'Turn text messages back on for this reader', + request: { + body: { + content: { + 'application/json': { + schema: z.object({ + disclosureVersion: z.number().int().optional() + .describe('Required for an express grant; ignored for an implied resume.'), + }), + }, + }, + }, + }, + responses: { + 200: { + content: { 'application/json': { schema: z.object({ success: z.literal(true) }) } }, + description: 'Recorded.', + }, + }, + operationId: 'grantStaffSmsConsent', + description: + 'Appends a granted row for the signed-in staff member. Staff are implied, so this ' + + 'withdraws an earlier stop rather than capturing consent — recorded under ' + + 'recipient_type staff so it never counts as consumer opt-in evidence.', +}, { scopes: ['write'], tier: 'extended' })); + const notificationPreferenceRoutes = createApiRouter() .openapi(getScreenRoute, async (c) => { const tenantId = c.get('tenantId') as string; @@ -173,6 +205,18 @@ const notificationPreferenceRoutes = createApiRouter() await revokeChannel(new SmsConsentService(c.env.DB), tenantId, 'sms', block, 'staff'); } return c.json({ success: true as const, stored }, 200); + }) + .openapi(grantRoute, async (c) => { + const tenantId = c.get('tenantId') as string; + const userId = c.get('user')?.sub as string; + const db = getDrizzle(c); + const block = await readSmsConsent(db, tenantId, 'staff', [{ kind: 'user', id: userId }], null); + if (!block) throw Errors.BadRequest('There is nothing to change here.'); + await grantSms(new SmsConsentService(c.env.DB), tenantId, block, 'staff', { + ip: c.req.header('cf-connecting-ip'), + userAgent: c.req.header('user-agent'), + }); + return c.json({ success: true as const }, 200); }); export default notificationPreferenceRoutes; diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json index 4cd46e9da..ee46a2631 100644 --- a/server/lib/mcp/openapi-snapshot.json +++ b/server/lib/mcp/openapi-snapshot.json @@ -8642,6 +8642,66 @@ "summary": "Get the current tenant's usage summary (inspections/sms/email/storage/seats + free-tier caps)", "description": "Returns the calling tenant's lifetime usage per metric, seat usage, and (free tier only) the caps those metrics are measured against." }, + { + "operationId": "grantAgentSmsConsent", + "method": "PUT", + "pathTemplate": "/api/agent/notification-preferences/sms-consent", + "scopes": [ + "agent" + ], + "tag": "agents", + "tier": "extended", + "inputSchema": { + "parameters": [], + "body": { + "type": "object", + "properties": { + "companyId": { + "type": "string", + "description": "Tenant id to resume at. Required unless scope is \"all\"." + }, + "scope": { + "type": "string", + "enum": [ + "company", + "all" + ], + "description": "\"all\" resumes at every company currently linked to this agent." + }, + "disclosureVersion": { + "type": "integer", + "description": "Ignored for an agent: implied consent has nothing to disclose." + } + } + } + }, + "summary": "Turn text messages back on at one company", + "description": "Withdraws an earlier stop at one company, or at every linked company. Agents are implied, so this records a resume under recipient_type agent — never consumer opt-in evidence." + }, + { + "operationId": "grantStaffSmsConsent", + "method": "PUT", + "pathTemplate": "/api/notification-preferences/sms-consent", + "scopes": [ + "write" + ], + "tag": "notifications", + "tier": "extended", + "inputSchema": { + "parameters": [], + "body": { + "type": "object", + "properties": { + "disclosureVersion": { + "type": "integer", + "description": "Required for an express grant; ignored for an implied resume." + } + } + } + }, + "summary": "Turn text messages back on for this reader", + "description": "Appends a granted row for the signed-in staff member. Staff are implied, so this withdraws an earlier stop rather than capturing consent — recorded under recipient_type staff so it never counts as consumer opt-in evidence." + }, { "operationId": "importContacts", "method": "POST", diff --git a/server/lib/notifications/channel-consent.ts b/server/lib/notifications/channel-consent.ts index 94a58fa9a..f02ca84ff 100644 --- a/server/lib/notifications/channel-consent.ts +++ b/server/lib/notifications/channel-consent.ts @@ -30,7 +30,15 @@ export interface SmsConsentBlock { /** When the state above was recorded. Null for `implied`. */ at: string | null; /** How it was captured — booking form, opt-in link, or by an admin. */ - capturedVia: 'booking_form' | 'optin_link' | 'admin' | null; + capturedVia: 'booking_form' | 'optin_link' | 'admin' | 'settings_page' | null; + /** + * Whether turning this back on is a GRANT or a RESUME. + * + * Decided HERE, where the audience is known, rather than by each screen. + * Three call sites setting it by hand is three chances for one to ask a + * staff member to acknowledge a consumer disclosure they never needed. + */ + mode: 'express' | 'implied'; /** * WHO a Stop writes against. A client or agent resolves to `contacts` rows; * a staff member is a single `users` row and has no contact at all. @@ -102,12 +110,14 @@ export async function readSmsConsent( return { phone, state: 'revoked', at: toIso(latest.createdAt), capturedVia: latest.capturedVia ?? null, subjects, disclosure, + mode: audience === 'client' ? 'express' : 'implied', }; } if (latest?.action === 'granted') { return { phone, state: 'granted', at: toIso(latest.createdAt), capturedVia: latest.capturedVia ?? null, subjects, disclosure, + mode: audience === 'client' ? 'express' : 'implied', }; } @@ -117,6 +127,7 @@ export async function readSmsConsent( return { phone, state: audience === 'client' ? 'none' : 'implied', at: null, capturedVia: null, subjects, disclosure, + mode: audience === 'client' ? 'express' : 'implied', }; } @@ -157,12 +168,26 @@ export interface ConsentRecorder { } /** - * Record that this reader granted the text channel, from this screen. + * Record that this reader turned the text channel back on. + * + * TWO DIFFERENT ACTS share this row, and the `recipient_type` column is what + * keeps them apart: + * + * - a CLIENT is granting express consent. Only legitimate when the disclosure + * was rendered and its version comes back with the acknowledgement, which is + * why the version is a parameter rather than something looked up here: a + * caller free to pass any version could record consent to text nobody saw. + * - a STAFF member or AGENT is RESUMING. They were reachable under an existing + * relationship all along and never granted anything, so there is no + * disclosure to show and nothing to acknowledge — the row withdraws their + * earlier stop. * - * Only legitimate when the disclosure was RENDERED and its version comes back - * with the acknowledgement — which is why the version is a parameter and not - * something this function looks up. A caller that could pass any version would - * be able to record consent to text the reader never saw. + * An earlier version refused the second case outright, on the reasoning that a + * staff `granted` row pollutes consumer evidence. That was too blunt: it built + * a one-way door, and a staff member who stopped could never start again. The + * separation belongs in the column, not in the absence of the row — a filing + * that counts opt-in evidence filters `recipient_type = 'client'`, which is + * the whole reason that column is not a boolean. */ export async function grantSms( recorder: ConsentRecorder, @@ -171,13 +196,9 @@ export async function grantSms( audience: Audience, meta: { ip?: string | undefined; userAgent?: string | undefined }, ): Promise { - // Only consumers are ever GRANTED here. Agents and staff are implied, and - // writing a grant for them would put non-consumer messaging inside the - // consumer consent evidence (docs/sms-compliance.md). - if (audience !== 'client') return; for (const s of block.subjects) { await recorder.record(tenantId, s.id, 'granted', 'settings_page', { - ...meta, recipientType: 'client', subjectKind: s.kind, + ...meta, recipientType: basisFor(audience), subjectKind: s.kind, }); } } diff --git a/tests/unit/notifications/channel-consent.spec.ts b/tests/unit/notifications/channel-consent.spec.ts index 2ccc5dd94..a7791afcd 100644 --- a/tests/unit/notifications/channel-consent.spec.ts +++ b/tests/unit/notifications/channel-consent.spec.ts @@ -63,19 +63,30 @@ describe('who may enter the consent ledger', () => { expect(rows[0]).toMatchObject({ recipientType: 'agent' }); }); - it('NEVER records a grant for staff', async () => { + it('lets STAFF resume, recorded under their own basis', async () => { + // Refusing this outright built a one-way door: a staff member who + // stopped could never start again. The separation belongs in the + // `recipient_type` column, not in the absence of the row. const { rec, rows } = recorder(); await grantSms(rec, TENANT, block([{ kind: 'user', id: 'u1' }]), 'staff', {}); - expect(rows).toEqual([]); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ action: 'granted', recipientType: 'staff', subjectKind: 'user' }); }); - it('NEVER records a grant for an agent', async () => { - // Implied consent has nothing to grant. A `granted` row here would put - // B2B messaging inside the consumer evidence — the pollution the - // layered program exists to prevent. + it('lets an AGENT resume, recorded as an agent', async () => { const { rec, rows } = recorder(); await grantSms(rec, TENANT, block([{ kind: 'contact', id: 'c1' }]), 'agent', {}); - expect(rows).toEqual([]); + expect(rows[0]).toMatchObject({ action: 'granted', recipientType: 'agent' }); + }); + + it('NEVER labels a non-consumer resume as client consent', async () => { + // THE invariant the ISV filing rests on. A query counting consumer + // opt-in evidence filters `recipient_type = 'client'`, so a staff or + // agent row stamped 'client' is the one mistake that would corrupt it. + const { rec, rows } = recorder(); + await grantSms(rec, TENANT, block([{ kind: 'user', id: 'u1' }]), 'staff', {}); + await grantSms(rec, TENANT, block([{ kind: 'contact', id: 'c1' }]), 'agent', {}); + expect(rows.some((r) => r.recipientType === 'client')).toBe(false); }); it('DOES record a grant for a client, with the evidence fields', async () => { From 44bfaa45bf501b3d28f3a72c76822cde971176b6 Mon Sep 17 00:00:00 2001 From: important-new Date: Fri, 31 Jul 2026 23:01:51 +0800 Subject: [PATCH 23/48] fix(profile): credentials are visible when added, and reach the signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects on Settings -> Profile, both found by looking at the page. ADDING A CREDENTIAL SHOWED NOTHING TO FILL IN. `onAdd` creates a blank row and the `
` was collapsed, so a new credential rendered as an upload box and the word "Details" with nothing saying what it was — the two fields hidden at exactly the moment they are needed. It opens by default now, and stays collapsible for someone who has already filled several in. The uploader column also went w-24 -> w-36: its own caption wrapped to three lines and read as a broken layout. CREDENTIALS NEVER REACHED THE EMAIL SIGNATURE. `inspectorSignature()` has accepted a `credentials` argument since Spec B and renders badges from it — and no caller ever passed one. The feature was wired and dead: the settings copy promises "shown on your reports, emails, and booking page" while the signature showed only the legacy `license_number` line. The preview now supplies the inspector's active credentials, so what a reader sees is what a recipient gets. Both are one half of a migration Spec B started and did not finish; the other half (retiring `users.license_number`, which still renders in the signature and the PDF footer) is written up rather than done, because deleting the field today would silently drop those two surfaces. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RgBRZQhGELdkaWGorWwWKV --- app/components/settings/CredentialsEditor.tsx | 13 ++++++++++--- server/api/profile.ts | 19 ++++++++++++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/app/components/settings/CredentialsEditor.tsx b/app/components/settings/CredentialsEditor.tsx index 66c3d4f69..8e329e3d7 100644 --- a/app/components/settings/CredentialsEditor.tsx +++ b/app/components/settings/CredentialsEditor.tsx @@ -40,12 +40,19 @@ export function CredentialsEditor({ )} {credentials.map((c) => ( -
-
+
+ {/* Wide enough for the uploader's own caption. At w-24 it wrapped to + three lines and read as a broken layout. */} +
onUpload(c.id, f)} />
-
+ {/* OPEN by default. `onAdd` creates a blank row, so a collapsed + one showed an upload box and the word "Details" with nothing + saying what the credential is — the two fields are hidden at + exactly the moment they are needed. Still collapsible, for a + reader who has already filled several in. */} +
{m.settings_profile_credentials_details_summary()}
k.active) + .map((k) => ({ + label: k.label, + memberNumber: k.memberNumber, + imageUrl: k.imageR2Key + ? `/api/public/brand-asset?key=${encodeURIComponent(k.imageR2Key)}` + : null, + })); + const signaturePreviewHtml = (row.name ?? '').trim() ? inspectorSignature({ name: row.name, email: row.email, phone: row.phone, - licenseNumber: row.licenseNumber, tenantSlug, + licenseNumber: row.licenseNumber, tenantSlug, credentials, }, host).html : ''; From c7419928a281aebc557250cf7125ddcbdd1b6329 Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 1 Aug 2026 00:14:19 +0800 Subject: [PATCH 24/48] fix(agent): a session ends at the door it was opened at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent portal's "Log out" pointed at `/logout`, and the teardown behind it ends with an unconditional `redirect("/login")`. `/login` is the STAFF front door — an agent has no account there — and under `APP_MODE=saas` that page 302s again to `${PORTAL_API_URL}/login`, out of this product entirely and onto a portal sign-in an agent cannot use at all. So logging out of the agent portal did not land on a login page; it landed on a dead end. The same wrong door was on the EXPIRY path, which is the one that fires without anybody clicking anything: `requireToken` throws `/login` on a missing token and routes an expired one through the same teardown, and it is `agent-layout`'s loader that calls it. An agent whose token aged out was dumped on the staff login too. `loginPathFor(request)` now derives the door from the path rather than taking it from each caller, because the callers are the two functions every agent loader and the logout route already go through — a caller-supplied argument is a thing a new agent surface can forget, and this one is only ever wrong in a direction nobody tests. `agent-logout` exists for the same reason: it is the same module as `/logout`, and the entire difference is that the path carries the signal. The prefix is the whole rule, which the spec pins in both directions: `/contacts` and `/inspections/agent-notes` are staff pages ABOUT agents and stay on the staff door. Also `/agent-signup`'s "already have an account?" link, which pointed at `/login` — the one page that cannot accept the account it was offering. Verified live on all four paths (302 Location): /agent-logout -> /agent-login, /logout -> /login, /agent-dashboard -> /agent-login, /inspections -> /login. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HcJgdRoQzrRJ8RvEcpUDR5 --- app/lib/session.server.test.ts | 67 ++++++++++++++++++++++++++++++++-- app/lib/session.server.ts | 28 +++++++++++++- app/routes.ts | 6 +++ app/routes/agent-layout.tsx | 2 +- app/routes/agent/signup.tsx | 5 ++- 5 files changed, 101 insertions(+), 7 deletions(-) diff --git a/app/lib/session.server.test.ts b/app/lib/session.server.test.ts index d7f532731..ce020ea74 100644 --- a/app/lib/session.server.test.ts +++ b/app/lib/session.server.test.ts @@ -42,14 +42,15 @@ function jwt(payload: Record): string { * Cookie is a forbidden header name in fetch — so the header is built directly. * requireToken only ever reads `request.headers.get("Cookie")`. */ -function requestWithCookie(cookie: string | null): Request { +function requestWithCookie(cookie: string | null, path = "/inspections"): Request { return { + url: `https://example.test${path}`, headers: new Headers(cookie ? { Cookie: cookie } : {}), } as unknown as Request; } -function requestWithToken(token: string): Request { - return requestWithCookie(`__Host-inspector_token=${token}`); +function requestWithToken(token: string, path?: string): Request { + return requestWithCookie(`__Host-inspector_token=${token}`, path); } const HOUR = 3600; @@ -102,6 +103,66 @@ describe("requireToken", () => { }); }); +/** + * A session ends at the door it was opened at. + * + * `/login` is the STAFF front door: an agent has no account there, and in SaaS + * mode `routes/login.tsx` 302s again to `${PORTAL_API_URL}/login` — out of this + * product entirely, onto a portal sign-in an agent cannot use. Sending an agent + * there on logout or on expiry is a dead end, not a login page. + * + * The door is derived from the path rather than passed in by each caller, so an + * agent route added later cannot forget to ask for it. + */ +describe("which login page a session ends on", () => { + const AGENT_PAGES = [ + "/agent-dashboard", + "/agent-settings/profile", + "/agent-inspectors", + "/agent-repair-items", + "/agent-logout", + ]; + const STAFF_PAGES = ["/inspections", "/settings/profile", "/logout", "/calendar"]; + + it("sends an agent with no session to the agent login", async () => { + for (const path of AGENT_PAGES) { + const res = await captureThrown(() => + requireToken(CONTEXT, requestWithCookie(null, path)), + ); + expect(res.headers.get("Location"), path).toBe("/agent-login"); + } + }); + + it("sends an agent whose session EXPIRED to the agent login", async () => { + for (const path of AGENT_PAGES) { + const res = await captureThrown(() => + requireToken(CONTEXT, requestWithToken(jwt({ sub: "a1", exp: nowSec() - HOUR }), path)), + ); + expect(res.headers.get("Location"), path).toBe("/agent-login"); + } + }); + + it("leaves every staff surface on the staff login", async () => { + for (const path of STAFF_PAGES) { + const res = await captureThrown(() => + requireToken(CONTEXT, requestWithCookie(null, path)), + ); + expect(res.headers.get("Location"), path).toBe("/login"); + } + }); + + it("does not treat a staff path that merely CONTAINS 'agent' as an agent surface", async () => { + // `/contacts?type=agent` and `/inspections/agent-notes` are staff pages + // about agents, not agent pages. The prefix is the whole rule. + for (const path of ["/contacts", "/inspections/agent-notes", "/reports/agentx"]) { + const res = await captureThrown(() => + requireToken(CONTEXT, requestWithCookie(null, path)), + ); + expect(res.headers.get("Location"), path).toBe("/login"); + } + }); +}); + /** * Two login paths, one credential — only one of them used to plant it. * diff --git a/app/lib/session.server.ts b/app/lib/session.server.ts index e3c2dac0a..6db8ab4a1 100644 --- a/app/lib/session.server.ts +++ b/app/lib/session.server.ts @@ -162,9 +162,33 @@ function isTokenExpired(token: string, nowMs: number): boolean { } } +/** + * Which sign-in page a session ends on. + * + * There are two front doors and they are not interchangeable. `/login` is for + * STAFF: an agent has no account there, and under `APP_MODE=saas` + * `routes/login.tsx` 302s again to `${PORTAL_API_URL}/login` — out of this + * product entirely, onto a portal sign-in an agent cannot use. So an agent sent + * to `/login` on logout or on expiry does not land on a login page; they land + * on a dead end. Agents sign in at `/agent-login`. + * + * Derived from the path rather than passed in by each caller, because the + * callers are `requireToken` and `destroyUserSession` — one is invoked by every + * agent loader and the other by the logout route, and an agent surface added + * later would otherwise have to remember to ask. Every agent page is mounted + * under the `agent-` prefix (`app/routes.ts`), including `agent-logout`, which + * exists so that the teardown route carries the same signal as the pages. + * + * The prefix is the whole rule: `/contacts` and `/inspections/agent-notes` are + * staff pages ABOUT agents and stay on the staff door. + */ +export function loginPathFor(request: Request): "/login" | "/agent-login" { + return new URL(request.url).pathname.startsWith("/agent-") ? "/agent-login" : "/login"; +} + export async function requireToken(context: LoadContext, request: Request): Promise { const token = await getToken(context, request); - if (!token) throw redirect("/login"); + if (!token) throw redirect(loginPathFor(request)); // An expired session is the ordinary end of a session, not a failure. Without // this, the cookie still EXISTS so the loader proceeded, every API call // answered 401, and the page fell into its error boundary — the visitor saw @@ -234,5 +258,5 @@ export async function destroyUserSession(context: LoadContext, request: Request) "Set-Cookie", "__Host-inspector_token=; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=0", ); - return redirect("/login", { headers }); + return redirect(loginPathFor(request), { headers }); } diff --git a/app/routes.ts b/app/routes.ts index 155e0226b..de10444d8 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -11,6 +11,12 @@ export default [ route("forgot-password", "routes/forgot-password.tsx"), route("reset-password", "routes/reset-password.tsx"), route("logout", "routes/logout.tsx"), + // The agent portal's own teardown path. Same module — the difference is + // entirely in the path, which is what `loginPathFor` reads to decide which + // sign-in page the session ends on. `/logout` would send an agent to the + // STAFF login (and, in SaaS, on to the portal's), which is not a door they + // have a key to. See app/lib/session.server.ts. + route("agent-logout", "routes/logout.tsx", { id: "agent-logout" }), // Remote MCP OAuth consent screen (B3). Bare route (own chrome, own auth // handling); the OAuthProvider wrapper routes /oauth/authorize here via the // defaultHandler, injecting env.OAUTH_PROVIDER for the loader/action. diff --git a/app/routes/agent-layout.tsx b/app/routes/agent-layout.tsx index 3570edb90..1ed8d6fde 100644 --- a/app/routes/agent-layout.tsx +++ b/app/routes/agent-layout.tsx @@ -109,7 +109,7 @@ export default function AgentLayout({ loaderData }: Route.ComponentProps) { {m.agent_portal_layout_logout()} diff --git a/app/routes/agent/signup.tsx b/app/routes/agent/signup.tsx index 528788cd0..eaf25acf9 100644 --- a/app/routes/agent/signup.tsx +++ b/app/routes/agent/signup.tsx @@ -304,7 +304,10 @@ export default function AgentSignupPage() {

{m.auth_agent_signup_have_account()}{" "} {m.auth_agent_signup_login_link()} From 228edd3f77e0fb90106bb93e3f55d0d3ffbf2f43 Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 1 Aug 2026 00:18:39 +0800 Subject: [PATCH 25/48] feat(notifications): a page to link to, for a reader who is not signed in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §4.1 asks the privacy policy and the terms to link to the notification control. There was nowhere to link. The client's copy of that surface hangs off the Hub bell, so its URL names an INSPECTION — and the people who follow a link out of a legal document are typically not standing on one, and often are not signed in at all. A link into the Hub would have been a link into an inspection they may not have open. So `/portal/:tenant/notifications` takes no inspection and assumes no session. Signed in, it renders the same `PortalNotificationSection` the Hub renders and its action listens for the same three intents through the same three helpers — one surface, one contract, because a divergence here is a switch that works on one entrance and not the other. SIGNED OUT, IT MUST NOT SAY WHETHER THE ADDRESS IS KNOWN. That property is already the API's (`request-link` is payload- AND timing-identical either way, the send deferred to waitUntil); this page's part is to never ask a question whose answer could differ, and to render the same conditional-voice panel every time — "If an account matches that address, a link is on its way." Verified with a known and an unknown address: identical body, 0.249s vs 0.256s. THE LINK HAS TO COME BACK HERE, and that is where this could have gone wrong. The obvious shape is a `next` path echoed into an outbound email — which is an open redirect with a delivery mechanism attached. `destination` is therefore an ENUM of two names, and `?to=` is matched against one literal rather than used as a path, so there is nothing for a crafted link to point at. The spec pins that `//evil.example`, `https://evil.example` and `/agent-dashboard` are all 400 at the schema, before any link is built. `redeemDestination` is a pure function and not a ternary in the loader because one of its four answers is a security property, not a routing preference: an agent-resolved redeem holds `__Host-inspector_token` and no `__Host-portal_session`, so it must never be handed a `/portal/` path. Its spec asserts the negative — an agent stays on `/agent-` in BOTH arms — which keeps holding if someone later "unifies" the two branches. An agent who asked for notifications lands on their own settings rather than the dashboard, so the link does not stop one page short of what it promised. server/api/portal.ts crossed its file-size cap by the 10 lines of the enum and its comment; baseline bumped rather than split, since splitting a route module is a refactor this change does not justify. Verified in Chrome, light and dark: signed-in surface, signed-out form, and the sent panel for an address that does not exist. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HcJgdRoQzrRJ8RvEcpUDR5 --- app/routes.ts | 5 + app/routes/public/portal-auth.test.tsx | 41 ++++ app/routes/public/portal-auth.tsx | 36 ++- app/routes/public/portal-notifications.tsx | 213 ++++++++++++++++++ messages/en/communication.json | 5 + scripts/file-size-baseline.json | 2 +- server/api/portal.ts | 14 +- .../unit/client-portal/portal-routes.spec.ts | 55 +++++ 8 files changed, 364 insertions(+), 7 deletions(-) create mode 100644 app/routes/public/portal-auth.test.tsx create mode 100644 app/routes/public/portal-notifications.tsx diff --git a/app/routes.ts b/app/routes.ts index de10444d8..7a8033f6a 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -60,6 +60,11 @@ export default [ route("portal/:tenant", "routes/public/portal.tsx"), route("portal/:tenant/auth", "routes/public/portal-auth.tsx"), route("portal/:tenant/i/:inspectionId", "routes/public/portal-inspection.tsx"), + // Notification settings as a page of its own, so the privacy policy and the + // terms have somewhere to link that names no inspection and assumes no + // session (spec §4.1). Signed out, it asks for an email and sends a + // one-time link back here — without saying whether the address is known. + route("portal/:tenant/notifications", "routes/public/portal-notifications.tsx"), ]), // Standalone pages (own chrome, no sidebar) route("setup", "routes/setup.tsx"), diff --git a/app/routes/public/portal-auth.test.tsx b/app/routes/public/portal-auth.test.tsx new file mode 100644 index 000000000..3c9eec05a --- /dev/null +++ b/app/routes/public/portal-auth.test.tsx @@ -0,0 +1,41 @@ +/** + * Where a redeemed magic-link lands — and, for an agent, where it must NOT. + * + * Adding `?to=notifications` gave the redemption a second destination, and the + * cheap way to write that is a ternary on the path. This spec exists because + * one of the four answers is not a routing preference: an agent-resolved redeem + * holds `__Host-inspector_token` and NO `__Host-portal_session`, so a `/portal/` + * path handed to an agent is the exact confusion the agent branch was built to + * prevent (server/api/portal.ts redeemRoute). + */ +import { describe, it, expect } from "vitest"; +import { redeemDestination } from "./portal-auth"; + +describe("redeemDestination", () => { + it("sends a client to the hub, and to notification settings when they asked for them", () => { + expect(redeemDestination({ agent: false, wantsNotifications: false, tenant: "acme" })) + .toBe("/portal/acme"); + expect(redeemDestination({ agent: false, wantsNotifications: true, tenant: "acme" })) + .toBe("/portal/acme/notifications"); + }); + + it("keeps an agent on agent surfaces in BOTH arms — never the client hub", () => { + for (const wantsNotifications of [true, false]) { + const dest = redeemDestination({ agent: true, wantsNotifications, tenant: "acme" }); + // The assertion that matters is the negative one. Pinning only the exact + // string would keep passing if someone later "unified" the two branches. + expect(dest.startsWith("/portal/"), String(wantsNotifications)).toBe(false); + expect(dest.startsWith("/agent-"), String(wantsNotifications)).toBe(true); + } + }); + + it("routes an agent who asked for notifications to their OWN settings, not the dashboard", () => { + // Someone who clicked "manage notifications" in a privacy policy asked for + // one thing. Dropping them on the dashboard is a link that lands one page + // short of what it promised. + expect(redeemDestination({ agent: true, wantsNotifications: true, tenant: "acme" })) + .toBe("/agent-settings/profile"); + expect(redeemDestination({ agent: true, wantsNotifications: false, tenant: "acme" })) + .toBe("/agent-dashboard"); + }); +}); diff --git a/app/routes/public/portal-auth.tsx b/app/routes/public/portal-auth.tsx index 7902347e0..376069a40 100644 --- a/app/routes/public/portal-auth.tsx +++ b/app/routes/public/portal-auth.tsx @@ -22,6 +22,29 @@ export function meta() { return [{ title: m.portal_auth_meta_title() }]; } +/** + * Where a redeemed link lands. + * + * A pure function, and separate from the loader, because one of its four + * answers is a SECURITY property rather than a routing preference: an + * agent-resolved redeem holds `__Host-inspector_token` and no + * `__Host-portal_session`, so it must never be handed a `/portal/` path — the + * client hub would answer it as an unauthenticated stranger at best, and the + * intent of the agent branch is that its report token can never unlock the + * client hub at all (server/api/portal.ts redeemRoute). + * + * `wantsNotifications` is a boolean by the time it arrives here, not a string + * and never a path: the caller matches `?to=` against one literal, so there is + * nothing for a crafted link to point at. + */ +export function redeemDestination( + { agent, wantsNotifications, tenant }: + { agent: boolean; wantsNotifications: boolean; tenant: string }, +): string { + if (agent) return wantsNotifications ? "/agent-settings/profile" : "/agent-dashboard"; + return wantsNotifications ? `/portal/${tenant}/notifications` : `/portal/${tenant}`; +} + export async function loader({ params, request, context }: Route.LoaderArgs) { const tenant = params.tenant ?? ""; const url = new URL(request.url); @@ -29,6 +52,10 @@ export async function loader({ params, request, context }: Route.LoaderArgs) { if (!link) { throw redirect(`/portal/${tenant}`); } + // `to` is matched against a fixed set and never used as a path. It arrives in + // a link inside an email, so treating it as one would be an open redirect the + // attacker gets to deliver. Anything unrecognised falls to the default. + const wantsNotifications = url.searchParams.get("to") === "notifications"; const api = createApi(context); try { @@ -39,10 +66,11 @@ export async function loader({ params, request, context }: Route.LoaderArgs) { if (res.status === 200) { const cookie = res.headers.get("set-cookie"); const body = (await res.json()) as { data?: { email: string; agent?: boolean } }; - // SECURITY: an agent-resolved redeem set __Host-inspector_token (NOT - // __Host-portal_session) — route to the agent dashboard, never the - // client hub. See server/api/portal.ts redeemRoute. - const destination = body.data?.agent === true ? "/agent-dashboard" : `/portal/${tenant}`; + const destination = redeemDestination({ + agent: body.data?.agent === true, + wantsNotifications, + tenant, + }); return redirect(destination, { headers: cookie ? { "Set-Cookie": cookie } : undefined, }); diff --git a/app/routes/public/portal-notifications.tsx b/app/routes/public/portal-notifications.tsx new file mode 100644 index 000000000..836f4980f --- /dev/null +++ b/app/routes/public/portal-notifications.tsx @@ -0,0 +1,213 @@ +/** + * The client's notification settings, as a page of its own. + * + * Route: /portal/:tenant/notifications + * - Signed in (valid __Host-portal_session cookie) → the same settings + * surface the Hub bell opens. + * - Signed out → an email entry form that requests a one-time link back to + * THIS page, and which never says whether the address is known. + * + * WHY A SECOND ENTRANCE TO A SCREEN THAT ALREADY EXISTS (spec §4.1). The Hub's + * copy of this surface hangs off an inspection: it is reached from the bell, + * and the URL names an inspection the reader happened to be standing on. The + * privacy policy and the terms have to link somewhere too, and they are read by + * people who are not standing anywhere — often not signed in, and with no + * inspection to name. A link into the Hub would be a link into an inspection + * they may not have open, which is why this route takes none. + * + * NO ENUMERATION. The signed-out form's response is identical whether or not + * the address is known — the same rule `portal/request-link` already follows, + * and here it matters more, because the address is being typed by someone + * following a link out of a public legal document. The API is the part that + * makes this true (its response is payload- AND timing-identical); this page + * simply never asks a question whose answer could differ, and always renders + * the same "check your email" panel. + * + * BFF only: every `/api/portal` call goes through the typed client, with the + * browser's portal-session cookie forwarded in explicitly (the typed client's + * fetch does not carry it). + */ +import { Form, useLoaderData, useActionData, useNavigation } from "react-router"; +import type { Route } from "./+types/portal-notifications"; +import { createApi } from "~/lib/api-client.server"; +import { resolveTenantBrand } from "~/lib/tenant-brand.server"; +import { brandTokens, EMPTY_BRAND, type TenantBrand } from "~/lib/brand"; +import { PortalNotificationSection } from "~/components/portal/hub/PortalNotificationSection"; +import { PublicLegalFooter } from "~/components/PublicLegalFooter"; +import { signOut } from "~/components/portal/sign-out"; +import { + loadNotificationsSection, + savePortalNotificationChoice, + bulkPortalNotificationChoice, + grantPortalSmsConsent, + type NotificationsLoaderResult, +} from "~/lib/portal-notification-preferences"; +import { Input, Button } from "@core/shared-ui"; +import { m } from "~/paraglide/messages"; + +export function meta() { + return [{ title: m.portal_notif_page_meta_title() }]; +} + +type LoaderResult = + | { authed: true; tenant: string; email: string; brand: TenantBrand; notifications: NotificationsLoaderResult } + | { authed: false; tenant: string; brand: TenantBrand }; + +export async function loader({ params, request, context }: Route.LoaderArgs): Promise { + const tenant = params.tenant ?? ""; + const api = createApi(context); + const cookie = request.headers.get("cookie") ?? ""; + + let brand: TenantBrand = EMPTY_BRAND; + try { + brand = await resolveTenantBrand(context, tenant, request); + } catch { + brand = EMPTY_BRAND; + } + + // `me` decides authed-vs-not, exactly as the portal landing does, so the two + // entrances agree about what "signed in" means. The preferences read is a + // separate call whose own failures surface as an in-page error rather than as + // a sign-in form — a reader with a live session must never be told to sign in + // because a query failed. + try { + const res = await api.portal[":tenant"].me.$get( + { param: { tenant } }, + { headers: { Cookie: cookie } }, + ); + if (res.status === 200) { + const body = (await res.json()) as { data?: { email: string } }; + const email = body.data?.email; + if (email) { + const notifications = await loadNotificationsSection(context, tenant, cookie); + return { authed: true, tenant, email, brand, notifications }; + } + } + } catch { + // fall through to the signed-out form + } + return { authed: false, tenant, brand }; +} + +export async function action({ params, request, context }: Route.ActionArgs) { + const tenant = params.tenant ?? ""; + const formData = await request.formData(); + const intent = String(formData.get("intent") ?? ""); + const cookie = request.headers.get("cookie") ?? ""; + + // The same three intents the Hub's action listens for, handled by the same + // three helpers. One surface, one contract — a divergence here would be a + // switch that works on one entrance and not the other. + if (intent === "notification-sms-grant") { + return { ...(await grantPortalSmsConsent(context, tenant, request, formData)), intent }; + } + if (intent === "notification-bulk") { + return { ...(await bulkPortalNotificationChoice(context, tenant, cookie, formData)), intent }; + } + if (intent === "notification-preference") { + return { ...(await savePortalNotificationChoice(context, tenant, cookie, formData)), intent }; + } + + // Signed-out: request a one-time link that comes back HERE. + const email = String(formData.get("email") ?? "").trim(); + if (!email) return { sent: false as const }; + try { + await createApi(context).portal[":tenant"]["request-link"].$post({ + param: { tenant }, + json: { email, destination: "notifications" }, + }); + } catch { + // The API never enumerates; we mirror that and always report "sent". + } + return { sent: true as const }; +} + +export default function PortalNotificationsPage() { + const data = useLoaderData(); + const actionData = useActionData(); + const navigation = useNavigation(); + const submitting = navigation.state === "submitting"; + + if (data.authed) { + return ( +

+ ); + } + + return ( +
+
+

+ {data.brand.companyName ?? m.portal_brand_eyebrow_fallback()} +

+

{m.portal_notif_signin_heading()}

+

{m.portal_notif_signin_subtitle()}

+
+ + {actionData && "sent" in actionData && actionData.sent ? ( +
+

{m.portal_landing_sent_title()}

+ {/* Deliberately conditional-voice: "if an account matches". Saying + "we sent you a link" would confirm the address is known, which is + the enumeration this whole flow exists to avoid. */} +

{m.portal_landing_sent_body()}

+

{m.portal_landing_sent_recovery()}

+
+ ) : ( +
+ + +
+ )} + + +
+ ); +} diff --git a/messages/en/communication.json b/messages/en/communication.json index 8969384b5..696f5baa6 100644 --- a/messages/en/communication.json +++ b/messages/en/communication.json @@ -60,6 +60,11 @@ "portal_notif_save_error": "Couldn't save that. Please try again.", "portal_notif_heading": "Notification settings", "portal_notif_desc": "These settings cover everything this company sends you, not just this inspection. Changing one here applies to all your inspections with them.", + "portal_notif_page_meta_title": "Notification settings - OpenInspection", + "portal_notif_signin_heading": "Manage your notifications", + "portal_notif_signin_subtitle": "Enter your email and we'll send you a secure link to your notification settings.", + "portal_notif_back_to_portal": "← Back to my inspections", + "portal_notif_submit": "Email me a link", "notice_bell_aria": "Notices ({count} unread)", "notice_bell_aria_none": "Notices", "notice_empty_title": "No notices yet", diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 435976de0..b3a97f7c7 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -37,9 +37,9 @@ "app/components/NewInspectionWizard.tsx": 530, "app/routes/settings-profile.tsx": 530, "server/api/inspections/media-studio.ts": 530, + "server/api/portal.ts": 525, "server/services/portal-access.service.ts": 525, "server/api/inspections/publish.ts": 516, - "server/api/portal.ts": 515, "app/components/settings/ManagedComplianceWizard.tsx": 514, "server/api/bookings/agreement.ts": 510, "server/services/inspection-request.service.ts": 501, diff --git a/server/api/portal.ts b/server/api/portal.ts index 7458c8888..4681a5dea 100644 --- a/server/api/portal.ts +++ b/server/api/portal.ts @@ -49,6 +49,15 @@ const TenantParam = z.object({ const RequestLinkBody = z.object({ email: z.string().email().describe('Recipient email address requesting a portal magic-link.'), + // An ENUM, deliberately, not a path or a URL. The value is echoed into a + // link inside an outbound email, so anything free-form here would be an + // open redirect with a delivery mechanism attached. Two named destinations + // cost nothing and cannot be pointed anywhere. + destination: z.enum(['portal', 'notifications']).optional().describe( + 'Where the magic-link should land. `portal` (default) = the inspections list; ' + + '`notifications` = the notification settings page, for a reader arriving from ' + + 'the privacy policy or terms.', + ), }); const RecipientInspectionSchema = z.object({ @@ -300,7 +309,7 @@ const portalRoutes = portalRouter const tenantId = resolveTenantId(c); if (!tenantId) return c.json({ error: 'Tenant not found' }, 404); - const { email } = c.req.valid('json'); + const { email, destination } = c.req.valid('json'); // Look up whether this email has ANY live client/co_client grant in this // tenant. Same DB the PortalService reads (mocked to the test DB in unit @@ -323,7 +332,8 @@ const portalRoutes = portalRouter const token = await signMagicLink(c.env.JWT_SECRET, email); const baseUrl = getBaseUrl(c).replace(/\/$/, ''); const slug = c.get('requestedTenantSlug') || ''; - const link = `${baseUrl}/portal/${slug}/auth?link=${encodeURIComponent(token)}`; + const to = destination === 'notifications' ? '&to=notifications' : ''; + const link = `${baseUrl}/portal/${slug}/auth?link=${encodeURIComponent(token)}${to}`; // The route mints the link; the email service renders the // email. Built here, this was the one account-access mail // with no tenant branding, no class and no editable copy. diff --git a/tests/unit/client-portal/portal-routes.spec.ts b/tests/unit/client-portal/portal-routes.spec.ts index 292bfc64d..47abeda82 100644 --- a/tests/unit/client-portal/portal-routes.spec.ts +++ b/tests/unit/client-portal/portal-routes.spec.ts @@ -203,6 +203,61 @@ describe('portal API', () => { expect(sendClientPortalLogin).not.toHaveBeenCalled(); }); + /** + * A reader who followed "manage your notifications" out of a privacy policy + * must come back to the notification settings, not to the inspections list + * — otherwise the link in a legal document lands one page short of the thing + * it promised, on a page that names no notification at all. + * + * The destination is an ENUM, never a path. The value is echoed into a link + * inside an outbound email, so a free-form field here would be an open + * redirect with a delivery mechanism attached. + */ + it('POST /request-link carries destination=notifications into the emailed link', async () => { + await seedInspection('insp1'); + await seedToken('insp1', 'a@x.com', 'client'); + const app = buildApp(); + const res = await app.request('/api/portal/acme/request-link', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'a@x.com', destination: 'notifications' }), + }, reqEnv()); + expect(res.status).toBe(200); + expect(sendClientPortalLogin).toHaveBeenCalledWith( + 'a@x.com', + expect.stringContaining('&to=notifications'), + ); + }); + + it('POST /request-link defaults to the portal, and refuses a destination that is not one of the two', async () => { + await seedInspection('insp1'); + await seedToken('insp1', 'a@x.com', 'client'); + const app = buildApp(); + + // Omitted → the inspections list, unchanged from before this existed. + await app.request('/api/portal/acme/request-link', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'a@x.com' }), + }, reqEnv()); + expect(sendClientPortalLogin).toHaveBeenCalledWith( + 'a@x.com', + expect.not.stringContaining('&to='), + ); + + // A path, an absolute URL, a protocol-relative host: all rejected by the + // schema before any link is built, so none of them can reach an inbox. + for (const destination of ['//evil.example', 'https://evil.example', '/agent-dashboard', 'notifications ']) { + const res = await app.request('/api/portal/acme/request-link', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'a@x.com', destination }), + }, reqEnv()); + expect(res.status, destination).toBe(400); + } + expect(sendClientPortalLogin).toHaveBeenCalledTimes(1); + }); + it('POST /request-link returns 404 when the tenant slug is unresolved', async () => { const app = buildApp(null); const res = await app.request('/api/portal/nope/request-link', { From 1671b9a7ac102b75f074b31e2ed512ad8555764c Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 1 Aug 2026 01:16:02 +0800 Subject: [PATCH 26/48] feat(legal): a tenant's Privacy and Terms now have a history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design §6A: the platform's own documents live in a repository, so a version registry there can hash the text and let git hold it. A tenant's `tenant_configs.privacy_body` is a mutable TEXT column with nothing behind it — so the same table shape ported across would prove that the text CHANGED while being unable to produce the text that changed, failing at exactly the moment somebody needs it. `tenant_legal_versions` therefore stores the BODY, copying the shape this codebase already uses for the same problem (`agreement_requests.content_snapshot` / `.content_hash`) rather than the platform's. That also sidesteps lifting private-repo source into public OSS. WHAT A ROW MEANS. One publish of one document. `version` is a date string where the inspection Agreement's is an auto-increment integer — the formats differ so a reader cannot mistake one object for the other, and they share no table, no counter and no acceptance flow. The two failure modes are opposite and both invisible from the settings page, so both are pinned: recording NOTHING (the handler never calls the historian), and recording on every PATCH (a tenant changes their booking hours and mints a new revision of their privacy policy). The comparison is on the content hash, so an unchanged body is a no-op. Removing the guard turns three specs red. The route spec found the first one for real: the harness's service stub had no `legalVersion`, the PATCH handler swallowed the failure — deliberately, since a version row is evidence ABOUT a save and must never cost the tenant the save itself — and the version table stayed empty. That is exactly the risk of a non-fatal write, and the spec is the compensating control. It now runs the real service over the test DB rather than a stub that could silently do nothing. DATES ARE THE TENANT'S. The service resolves the timezone itself instead of trusting a caller: 2026-08-01 in UTC is still July 31 across the Americas for most of the day, and a "last updated" one day ahead of the company's own calendar only ever surfaces as a complaint. Same-day republishes collapse onto the text that ENDED the day, which is the one anything downstream could have relied on. "Last updated" replaces a HARDCODED literal — `public_legal_effective` read "Effective: July 30, 2026" and was shown on every tenant's page whatever their document said, stale from the release that shipped it. The replacement is string arithmetic with no `Date` anywhere, because the value is already a civil date and parsing it as a UTC instant is how it would render as the previous day. Null until a tenant publishes, and the line is omitted rather than invented. Also answers an open question in §6A.5 while passing through it: the hosted page renders the custom body in `whitespace-pre-wrap`, i.e. as PLAIN TEXT. A Markdown quick-insert affordance would ship as literal characters, so that feature stays out until the page renders Markdown. The table is declared in the erasure manifest as out of scope with reasons rather than left silent — the PII heuristic flags nothing here, and silence is not a decision. Migration 0021 is a bare CREATE TABLE: no rebuilds, so it does not touch `sms_consent_log`. `db:check` clean. Read path verified end to end against local D1: row -> API `lastUpdated: "2026-08-01"` -> page "Last updated August 1, 2026". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HcJgdRoQzrRJ8RvEcpUDR5 --- app/routes/public/legal.test.tsx | 40 + app/routes/public/legal.tsx | 42 +- messages/en/public.json | 4 +- migrations/0021_gigantic_bruce_banner.sql | 14 + migrations/meta/0021_snapshot.json | 10126 ++++++++++++++++ migrations/meta/_journal.json | 7 + scripts/file-size-baseline.json | 4 +- server/api/admin/admin-settings.ts | 29 + server/api/public/inspector-profile.ts | 11 +- server/lib/compliance/erasure-manifest.ts | 8 + server/lib/db/schema/tenant/index.ts | 1 + server/lib/db/schema/tenant/legal-versions.ts | 58 + server/lib/mcp/openapi-snapshot.json | 8 + server/lib/middleware/di.ts | 5 + server/lib/tz.ts | 11 + server/services/legal-version.service.ts | 137 + server/types/hono.ts | 3 + .../unit/legal/legal-version.service.spec.ts | 132 + .../messaging/compliance-settings-api.spec.ts | 92 + 19 files changed, 10723 insertions(+), 9 deletions(-) create mode 100644 app/routes/public/legal.test.tsx create mode 100644 migrations/0021_gigantic_bruce_banner.sql create mode 100644 migrations/meta/0021_snapshot.json create mode 100644 server/lib/db/schema/tenant/legal-versions.ts create mode 100644 server/services/legal-version.service.ts create mode 100644 tests/unit/legal/legal-version.service.spec.ts diff --git a/app/routes/public/legal.test.tsx b/app/routes/public/legal.test.tsx new file mode 100644 index 000000000..f9da7e01a --- /dev/null +++ b/app/routes/public/legal.test.tsx @@ -0,0 +1,40 @@ +/** + * The hosted legal page's "Last updated" line. + * + * It replaced a hardcoded literal in the message catalogue — one date, shown on + * EVERY tenant's page whatever their document said, and stale from the release + * that shipped it. The replacement's only real hazard is the one every civil-date + * bug in this codebase has come from. + */ +import { describe, it, expect } from "vitest"; +import { formatLegalVersion } from "./legal"; + +describe("formatLegalVersion", () => { + it("renders a civil date the way a reader writes one", () => { + expect(formatLegalVersion("2026-08-01")).toBe("August 1, 2026"); + expect(formatLegalVersion("2026-01-01")).toBe("January 1, 2026"); + expect(formatLegalVersion("2026-12-31")).toBe("December 31, 2026"); + }); + + it("does not go through Date, so it cannot land on the previous day", () => { + // The input is ALREADY the tenant's civil date, computed in their timezone. + // `new Date("2026-08-01")` parses as UTC midnight, and reading local parts + // back off it renders July 31 for every reader west of Greenwich — which is + // exactly the class of bug `lint:tz` exists to catch. Asserting the boundary + // dates is what makes this spec fail if someone "simplifies" it to + // toLocaleDateString. + expect(formatLegalVersion("2026-03-01")).toBe("March 1, 2026"); + expect(formatLegalVersion("2026-01-31")).toBe("January 31, 2026"); + }); + + it("says nothing when nothing has been published", () => { + // Null means the tenant has never published. The page then omits the line + // rather than inventing a date. + expect(formatLegalVersion(null)).toBeNull(); + }); + + it("passes an unrecognised value through instead of guessing at it", () => { + expect(formatLegalVersion("not-a-date")).toBe("not-a-date"); + expect(formatLegalVersion("2026-13-01")).toBe("2026-13-01"); + }); +}); diff --git a/app/routes/public/legal.tsx b/app/routes/public/legal.tsx index 243e65350..4161e4a5f 100644 --- a/app/routes/public/legal.tsx +++ b/app/routes/public/legal.tsx @@ -31,6 +31,29 @@ export function mergeCompany(template: string, company: string | null): string { return template.replace(/\{\{company\}\}/g, company ?? "[Your Company]"); } +const MONTHS = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +]; + +/** + * `2026-08-01` -> `August 1, 2026`. + * + * Deliberately string arithmetic and no `Date` anywhere. The input is already a + * civil date computed in the TENANT's timezone; handing it to `new Date()` here + * would re-interpret it as a UTC instant and then read local parts back off it, + * which is how a date renders as the day before for every reader west of + * Greenwich. There is no instant in this value to lose. + */ +export function formatLegalVersion(version: string | null): string | null { + if (!version) return null; + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(version); + if (!m) return version; + const month = MONTHS[Number(m[2]) - 1]; + if (!month) return version; + return `${month} ${Number(m[3])}, ${m[1]}`; +} + export async function loader({ params, context }: Route.LoaderArgs) { const { tenant, doc } = params; @@ -46,7 +69,7 @@ export async function loader({ params, context }: Route.LoaderArgs) { throw new Response(null, { status: 404 }); } const body = (await res.json()) as { - data?: { companyName?: string; body?: string | null }; + data?: { companyName?: string; body?: string | null; lastUpdated?: string | null }; }; const companyName = body.data?.companyName?.trim(); if (!companyName) { @@ -57,6 +80,7 @@ export async function loader({ params, context }: Route.LoaderArgs) { doc, companyName, customBody: body.data?.body?.trim() || null, + lastUpdated: body.data?.lastUpdated ?? null, tenantSlug: tenant, }; } @@ -229,12 +253,18 @@ function TermsContent({ company }: { company: string }) { } export default function LegalPage() { - const { doc, companyName, customBody } = useLoaderData(); + const { doc, companyName, customBody, lastUpdated } = useLoaderData(); const isPrivacy = doc === "privacy"; const docTitle = isPrivacy ? m.public_legal_doc_privacy() : m.public_legal_doc_terms(); const heading = m.public_legal_title({ doc: docTitle, company: companyName }); - const effectiveDate = m.public_legal_effective(); + // Was a hardcoded literal — one date, baked into the message catalogue, shown + // on EVERY tenant's page whatever their document said or when they last + // touched it, and stale the moment the release shipped. It now comes from the + // version registry, which is the only thing that knows a save changed the + // TEXT rather than some neighbouring setting. Null until a tenant has + // published once, and then the line is omitted rather than invented. + const effectiveDate = formatLegalVersion(lastUpdated); return (
@@ -246,7 +276,11 @@ export default function LegalPage() {

{heading}

-

{effectiveDate}

+ {effectiveDate && ( +

+ {m.public_legal_last_updated({ date: effectiveDate })} +

+ )}
diff --git a/messages/en/public.json b/messages/en/public.json index b096158f9..8c45bf67f 100644 --- a/messages/en/public.json +++ b/messages/en/public.json @@ -47,7 +47,6 @@ "public_legal_doc_privacy": "Privacy Policy", "public_legal_doc_terms": "Terms of Service", "public_legal_title": "{doc} — {company}", - "public_legal_effective": "Effective: July 30, 2026", "public_legal_powered_by": "Powered by", "public_legal_provided_by": ". Inspection services provided by {company}.", "agent_portal_dashboard_meta_title": "Agent Dashboard - OpenInspection", @@ -164,5 +163,6 @@ "oauth_authorize_aria_write": "Write {module}", "oauth_authorize_scope_note": "Ticking Write also grants Read. Access is limited to your role and to what {clientName} requested.", "oauth_authorize_submit_pending": "Authorizing…", - "oauth_authorize_submit": "Authorize" + "oauth_authorize_submit": "Authorize", + "public_legal_last_updated": "Last updated {date}" } diff --git a/migrations/0021_gigantic_bruce_banner.sql b/migrations/0021_gigantic_bruce_banner.sql new file mode 100644 index 000000000..f36d0a2a9 --- /dev/null +++ b/migrations/0021_gigantic_bruce_banner.sql @@ -0,0 +1,14 @@ +CREATE TABLE `tenant_legal_versions` ( + `id` text PRIMARY KEY NOT NULL, + `tenant_id` text NOT NULL, + `doc` text NOT NULL, + `version` text NOT NULL, + `body_snapshot` text, + `content_hash` text NOT NULL, + `is_material` integer DEFAULT false NOT NULL, + `published_at` integer NOT NULL, + `published_by_user_id` text +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_tenant_legal_versions_doc_version` ON `tenant_legal_versions` (`tenant_id`,`doc`,`version`);--> statement-breakpoint +CREATE INDEX `idx_tenant_legal_versions_latest` ON `tenant_legal_versions` (`tenant_id`,`doc`,`published_at`); \ No newline at end of file diff --git a/migrations/meta/0021_snapshot.json b/migrations/meta/0021_snapshot.json new file mode 100644 index 000000000..587f7082a --- /dev/null +++ b/migrations/meta/0021_snapshot.json @@ -0,0 +1,10126 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "824f1eee-c293-486c-9cd2-d9a1783e5347", + "prevId": "595f38f0-07a0-45bf-9483-8ff1bcd9d296", + "tables": { + "agreement_requests": { + "name": "agreement_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agreement_id": { + "name": "agreement_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "signature_base64": { + "name": "signature_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_at": { + "name": "signed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "viewed_at": { + "name": "viewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_at": { + "name": "sent_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_signature_base64": { + "name": "inspector_signature_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_signed_at": { + "name": "inspector_signed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_user_id": { + "name": "inspector_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_snapshot": { + "name": "content_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completion_policy": { + "name": "completion_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'all'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purged_at": { + "name": "purged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agreement_requests_token_unique": { + "name": "agreement_requests_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "idx_agreement_requests_verify_token": { + "name": "idx_agreement_requests_verify_token", + "columns": [ + "verification_token" + ], + "isUnique": true + }, + "idx_agreement_requests_tenant": { + "name": "idx_agreement_requests_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_agreement_requests_inspection": { + "name": "idx_agreement_requests_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "idx_agreement_requests_token_hash": { + "name": "idx_agreement_requests_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "agreement_requests_tenant_id_tenants_id_fk": { + "name": "agreement_requests_tenant_id_tenants_id_fk", + "tableFrom": "agreement_requests", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agreement_requests_inspection_id_inspections_id_fk": { + "name": "agreement_requests_inspection_id_inspections_id_fk", + "tableFrom": "agreement_requests", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agreement_requests_agreement_id_agreements_id_fk": { + "name": "agreement_requests_agreement_id_agreements_id_fk", + "tableFrom": "agreement_requests", + "tableTo": "agreements", + "columnsFrom": [ + "agreement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agreement_requests_inspector_user_id_users_id_fk": { + "name": "agreement_requests_inspector_user_id_users_id_fk", + "tableFrom": "agreement_requests", + "tableTo": "users", + "columnsFrom": [ + "inspector_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agreement_signers": { + "name": "agreement_signers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'client'" + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_enc": { + "name": "token_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "signature_base64": { + "name": "signature_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_at": { + "name": "signed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "viewed_at": { + "name": "viewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "on_behalf_of": { + "name": "on_behalf_of", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "on_behalf_disclaimer": { + "name": "on_behalf_disclaimer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_reminded_at": { + "name": "last_reminded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_agreement_signers_tenant_request": { + "name": "idx_agreement_signers_tenant_request", + "columns": [ + "tenant_id", + "request_id" + ], + "isUnique": false + }, + "idx_agreement_signers_request_email": { + "name": "idx_agreement_signers_request_email", + "columns": [ + "request_id", + "email" + ], + "isUnique": true + }, + "idx_agreement_signers_token_hash": { + "name": "idx_agreement_signers_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agreements": { + "name": "agreements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_agreements_tenant": { + "name": "idx_agreements_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "agreements_tenant_id_tenants_id_fk": { + "name": "agreements_tenant_id_tenants_id_fk", + "tableFrom": "agreements", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automation_logs": { + "name": "automation_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_role_key": { + "name": "recipient_role_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'email'" + }, + "send_at": { + "name": "send_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_contact_id": { + "name": "recipient_contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notice_id": { + "name": "notice_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_logs_pending": { + "name": "idx_automation_logs_pending", + "columns": [ + "tenant_id", + "status", + "send_at" + ], + "isUnique": false + }, + "idx_automation_logs_insp": { + "name": "idx_automation_logs_insp", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "uq_automation_logs_event": { + "name": "uq_automation_logs_event", + "columns": [ + "automation_id", + "inspection_id", + "event_id", + "channel", + "recipient" + ], + "isUnique": true, + "where": "event_id IS NOT NULL" + } + }, + "foreignKeys": { + "automation_logs_tenant_id_tenants_id_fk": { + "name": "automation_logs_tenant_id_tenants_id_fk", + "tableFrom": "automation_logs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automations": { + "name": "automations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_kind": { + "name": "recipient_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_role_profile_id": { + "name": "recipient_role_profile_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delay_minutes": { + "name": "delay_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "subject_template": { + "name": "subject_template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body_template": { + "name": "body_template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_template_id": { + "name": "email_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "conditions": { + "name": "conditions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channels": { + "name": "channels", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"email\"]'" + }, + "sms_body": { + "name": "sms_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sms_template_id": { + "name": "sms_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "in_app_template_id": { + "name": "in_app_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automations_tenant": { + "name": "idx_automations_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automations_tenant_id_tenants_id_fk": { + "name": "automations_tenant_id_tenants_id_fk", + "tableFrom": "automations", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "availability": { + "name": "availability", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_availability_inspector": { + "name": "idx_availability_inspector", + "columns": [ + "inspector_id" + ], + "isUnique": false + }, + "idx_availability_window_unique": { + "name": "idx_availability_window_unique", + "columns": [ + "inspector_id", + "day_of_week", + "start_time" + ], + "isUnique": true + } + }, + "foreignKeys": { + "availability_tenant_id_tenants_id_fk": { + "name": "availability_tenant_id_tenants_id_fk", + "tableFrom": "availability", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "availability_inspector_id_users_id_fk": { + "name": "availability_inspector_id_users_id_fk", + "tableFrom": "availability", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "availability_overrides": { + "name": "availability_overrides", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_available": { + "name": "is_available", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "start_time": { + "name": "start_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transparency": { + "name": "transparency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_avail_overrides_insp": { + "name": "idx_avail_overrides_insp", + "columns": [ + "inspector_id" + ], + "isUnique": false + }, + "idx_avail_overrides_block_unique": { + "name": "idx_avail_overrides_block_unique", + "columns": [ + "inspector_id", + "date" + ], + "isUnique": true, + "where": "is_available = 0 AND source IS NULL" + }, + "uq_avail_overrides_external": { + "name": "uq_avail_overrides_external", + "columns": [ + "inspector_id", + "source", + "external_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "availability_overrides_tenant_id_tenants_id_fk": { + "name": "availability_overrides_tenant_id_tenants_id_fk", + "tableFrom": "availability_overrides", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "availability_overrides_inspector_id_users_id_fk": { + "name": "availability_overrides_inspector_id_users_id_fk", + "tableFrom": "availability_overrides", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "calendar_blocks": { + "name": "calendar_blocks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_all_day": { + "name": "is_all_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_calendar_blocks_tenant_user_date": { + "name": "idx_calendar_blocks_tenant_user_date", + "columns": [ + "tenant_id", + "user_id", + "date" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "calendar_connection_read_calendars": { + "name": "calendar_connection_read_calendars", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_calendar_id": { + "name": "external_calendar_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_role": { + "name": "access_role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_conn_read_cal": { + "name": "uq_conn_read_cal", + "columns": [ + "connection_id", + "external_calendar_id" + ], + "isUnique": true + }, + "idx_conn_read_cal_tenant": { + "name": "idx_conn_read_cal_tenant", + "columns": [ + "tenant_id", + "connection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "calendar_connections": { + "name": "calendar_connections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials_enc": { + "name": "credentials_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials_dek_enc": { + "name": "credentials_dek_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "calendar_id": { + "name": "calendar_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connected_at": { + "name": "connected_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_calendar_connections_user_provider": { + "name": "uq_calendar_connections_user_provider", + "columns": [ + "user_id", + "provider" + ], + "isUnique": true + }, + "idx_calendar_connections_tenant_user": { + "name": "idx_calendar_connections_tenant_user", + "columns": [ + "tenant_id", + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comment_usage": { + "name": "comment_usage", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_comment_usage_user_last_used": { + "name": "idx_comment_usage_user_last_used", + "columns": [ + "tenant_id", + "user_id", + "last_used_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "comment_usage_comment_id_comments_id_fk": { + "name": "comment_usage_comment_id_comments_id_fk", + "tableFrom": "comment_usage", + "tableTo": "comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "comment_usage_tenant_id_user_id_comment_id_pk": { + "columns": [ + "tenant_id", + "user_id", + "comment_id" + ], + "name": "comment_usage_tenant_id_user_id_comment_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rating_bucket": { + "name": "rating_bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section": { + "name": "section", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_ids": { + "name": "section_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_labels": { + "name": "item_labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger_code": { + "name": "trigger_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "search_keywords": { + "name": "search_keywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_label": { + "name": "item_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repair_summary": { + "name": "repair_summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimate_min_cents": { + "name": "estimate_min_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimate_max_cents": { + "name": "estimate_max_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recommended_contractor_type_id": { + "name": "recommended_contractor_type_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_comments_tenant": { + "name": "idx_comments_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_comments_rating_bucket": { + "name": "idx_comments_rating_bucket", + "columns": [ + "tenant_id", + "rating_bucket" + ], + "isUnique": false + }, + "idx_comments_library_id": { + "name": "idx_comments_library_id", + "columns": [ + "library_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "comments_tenant_id_tenants_id_fk": { + "name": "comments_tenant_id_tenants_id_fk", + "tableFrom": "comments", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commercial_subtypes": { + "name": "commercial_subtypes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "based_on": { + "name": "based_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_disabled": { + "name": "is_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_commercial_subtypes_tenant_name": { + "name": "idx_commercial_subtypes_tenant_name", + "columns": [ + "tenant_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "commercial_subtypes_tenant_id_tenants_id_fk": { + "name": "commercial_subtypes_tenant_id_tenants_id_fk", + "tableFrom": "commercial_subtypes", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "concierge_confirm_tokens": { + "name": "concierge_confirm_tokens", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_concierge_tokens_expiry": { + "name": "idx_concierge_tokens_expiry", + "columns": [ + "expires_at" + ], + "isUnique": false + }, + "idx_concierge_confirm_token_hash": { + "name": "idx_concierge_confirm_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "concierge_confirm_tokens_inspection_id_inspections_id_fk": { + "name": "concierge_confirm_tokens_inspection_id_inspections_id_fk", + "tableFrom": "concierge_confirm_tokens", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contact_role_profiles": { + "name": "contact_role_profiles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_template_id": { + "name": "email_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sms_template_id": { + "name": "sms_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capability_overrides": { + "name": "capability_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_crp_tenant": { + "name": "idx_crp_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_crp_tenant_key": { + "name": "uq_crp_tenant_key", + "columns": [ + "tenant_id", + "key" + ], + "isUnique": true, + "where": "is_active = 1" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contacts": { + "name": "contacts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'client'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agency": { + "name": "agency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_user_id": { + "name": "agent_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_linked_at": { + "name": "agent_linked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_revoked_at": { + "name": "agent_revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_contacts_type": { + "name": "idx_contacts_type", + "columns": [ + "tenant_id", + "type" + ], + "isUnique": false + }, + "idx_contacts_tenant": { + "name": "idx_contacts_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_contacts_tenant_email": { + "name": "uq_contacts_tenant_email", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": true, + "where": "email IS NOT NULL AND archived_at IS NULL" + }, + "uq_contacts_tenant_agent_user": { + "name": "uq_contacts_tenant_agent_user", + "columns": [ + "tenant_id", + "agent_user_id" + ], + "isUnique": true, + "where": "agent_user_id IS NOT NULL AND archived_at IS NULL" + }, + "idx_contacts_agent_user": { + "name": "idx_contacts_agent_user", + "columns": [ + "agent_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "contacts_tenant_id_tenants_id_fk": { + "name": "contacts_tenant_id_tenants_id_fk", + "tableFrom": "contacts", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contractor_types": { + "name": "contractor_types", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_contractor_types_tenant": { + "name": "idx_contractor_types_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cost_items": { + "name": "cost_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "building_id": { + "name": "building_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_index": { + "name": "instance_index", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit_id": { + "name": "unit_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finding_key": { + "name": "finding_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "system": { + "name": "system", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "component": { + "name": "component", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cost_method": { + "name": "cost_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uom": { + "name": "uom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit_cost_cents": { + "name": "unit_cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lump_sum_cents": { + "name": "lump_sum_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "eul": { + "name": "eul", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "eff_age": { + "name": "eff_age", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rul": { + "name": "rul", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suggested_remedy": { + "name": "suggested_remedy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_ref": { + "name": "section_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "photo_ref": { + "name": "photo_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_cost_items_tenant_inspection": { + "name": "idx_cost_items_tenant_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "idx_cost_items_finding_key": { + "name": "idx_cost_items_finding_key", + "columns": [ + "finding_key" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "defect_categories": { + "name": "defect_categories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#6b7280'" + }, + "is_summary_driver": { + "name": "is_summary_driver", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_seed": { + "name": "is_seed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_defect_categories_tenant": { + "name": "idx_defect_categories_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "discount_codes": { + "name": "discount_codes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uses_count": { + "name": "uses_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_discount_codes_tenant": { + "name": "idx_discount_codes_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_discount_codes_code_tenant": { + "name": "uq_discount_codes_code_tenant", + "columns": [ + "upper(code)", + "tenant_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "discount_codes_tenant_id_tenants_id_fk": { + "name": "discount_codes_tenant_id_tenants_id_fk", + "tableFrom": "discount_codes", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_review_items": { + "name": "document_review_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_key": { + "name": "document_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_requested": { + "name": "is_requested", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_received": { + "name": "is_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_reviewed": { + "name": "is_reviewed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_na": { + "name": "is_na", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_doc_review_inspection": { + "name": "idx_doc_review_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "uq_doc_review_item": { + "name": "uq_doc_review_item", + "columns": [ + "inspection_id", + "document_key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_suppressions": { + "name": "email_suppressions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_provider": { + "name": "source_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_event_id": { + "name": "provider_event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_email_suppressions_email": { + "name": "idx_email_suppressions_email", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "erasure_log": { + "name": "erasure_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_email": { + "name": "subject_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identity_basis": { + "name": "identity_basis", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decisions_json": { + "name": "decisions_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retained_count": { + "name": "retained_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "anonymized_count": { + "name": "anonymized_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "deleted_count": { + "name": "deleted_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_note": { + "name": "response_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_erasure_log_tenant": { + "name": "idx_erasure_log_tenant", + "columns": [ + "tenant_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "esign_audit_logs": { + "name": "esign_audit_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prev_hash": { + "name": "prev_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_fingerprint": { + "name": "key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_esign_audit_logs_request": { + "name": "idx_esign_audit_logs_request", + "columns": [ + "tenant_id", + "request_id", + "created_at" + ], + "isUnique": false + }, + "idx_esign_audit_logs_event_dedup": { + "name": "idx_esign_audit_logs_event_dedup", + "columns": [ + "tenant_id", + "request_id", + "event" + ], + "isUnique": true, + "where": "event NOT LIKE 'signer.%'" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_types": { + "name": "event_types", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_duration_min": { + "name": "default_duration_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "default_price_cents": { + "name": "default_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#6366f1'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_event_types_tenant_slug": { + "name": "uq_event_types_tenant_slug", + "columns": [ + "tenant_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_types_tenant_id_tenants_id_fk": { + "name": "event_types_tenant_id_tenants_id_fk", + "tableFrom": "event_types", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_access_tokens": { + "name": "inspection_access_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'client'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_enc": { + "name": "token_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_iat_token": { + "name": "idx_iat_token", + "columns": [ + "token" + ], + "isUnique": true + }, + "idx_iat_inspection": { + "name": "idx_iat_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "idx_iat_recipient": { + "name": "idx_iat_recipient", + "columns": [ + "inspection_id", + "recipient_email" + ], + "isUnique": true + }, + "idx_iat_token_hash": { + "name": "idx_iat_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "inspection_access_tokens_tenant_id_tenants_id_fk": { + "name": "inspection_access_tokens_tenant_id_tenants_id_fk", + "tableFrom": "inspection_access_tokens", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_events": { + "name": "inspection_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type_id": { + "name": "event_type_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_min": { + "name": "duration_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'scheduled'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "results_received_at": { + "name": "results_received_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gcal_event_id": { + "name": "gcal_event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_events_scheduled": { + "name": "idx_inspection_events_scheduled", + "columns": [ + "tenant_id", + "scheduled_at" + ], + "isUnique": false + }, + "idx_inspection_events_inspection": { + "name": "idx_inspection_events_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspection_events_tenant_id_tenants_id_fk": { + "name": "inspection_events_tenant_id_tenants_id_fk", + "tableFrom": "inspection_events", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_events_inspection_id_inspections_id_fk": { + "name": "inspection_events_inspection_id_inspections_id_fk", + "tableFrom": "inspection_events", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inspection_events_event_type_id_event_types_id_fk": { + "name": "inspection_events_event_type_id_event_types_id_fk", + "tableFrom": "inspection_events", + "tableTo": "event_types", + "columnsFrom": [ + "event_type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_events_inspector_id_users_id_fk": { + "name": "inspection_events_inspector_id_users_id_fk", + "tableFrom": "inspection_events", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_inspectors": { + "name": "inspection_inspectors", + "columns": { + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'lead'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_insp_inspectors_tenant_user": { + "name": "idx_insp_inspectors_tenant_user", + "columns": [ + "tenant_id", + "user_id" + ], + "isUnique": false + }, + "idx_insp_inspectors_user": { + "name": "idx_insp_inspectors_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "inspection_inspectors_inspection_id_user_id_pk": { + "columns": [ + "inspection_id", + "user_id" + ], + "name": "inspection_inspectors_inspection_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_item_tag_links": { + "name": "inspection_item_tag_links", + "columns": { + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_tag_links_tenant": { + "name": "idx_tag_links_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_tag_links_tag": { + "name": "idx_tag_links_tag", + "columns": [ + "tag_id" + ], + "isUnique": false + }, + "idx_tag_links_inspection_item": { + "name": "idx_tag_links_inspection_item", + "columns": [ + "inspection_id", + "item_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "inspection_item_tag_links_inspection_id_item_id_tag_id_pk": { + "columns": [ + "inspection_id", + "item_id", + "tag_id" + ], + "name": "inspection_item_tag_links_inspection_id_item_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_media_pool": { + "name": "inspection_media_pool", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exif_data": { + "name": "exif_data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "annotations": { + "name": "annotations", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'photo'" + }, + "stream_uid": { + "name": "stream_uid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "poster_pct": { + "name": "poster_pct", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_sec": { + "name": "duration_sec", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'stream'" + }, + "poster_key": { + "name": "poster_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_media_pool_tenant": { + "name": "idx_media_pool_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_media_pool_inspection": { + "name": "idx_media_pool_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_messages": { + "name": "inspection_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "from_role": { + "name": "from_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachments": { + "name": "attachments", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_user_id": { + "name": "from_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_msg_inspection": { + "name": "idx_msg_inspection", + "columns": [ + "inspection_id", + "created_at" + ], + "isUnique": false + }, + "idx_msg_contact": { + "name": "idx_msg_contact", + "columns": [ + "tenant_id", + "contact_id", + "created_at" + ], + "isUnique": false + }, + "idx_msg_unread": { + "name": "idx_msg_unread", + "columns": [ + "tenant_id", + "contact_id", + "from_role" + ], + "isUnique": false, + "where": "\"inspection_messages\".\"read_at\" IS NULL" + } + }, + "foreignKeys": { + "inspection_messages_tenant_id_tenants_id_fk": { + "name": "inspection_messages_tenant_id_tenants_id_fk", + "tableFrom": "inspection_messages", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_messages_inspection_id_inspections_id_fk": { + "name": "inspection_messages_inspection_id_inspections_id_fk", + "tableFrom": "inspection_messages", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_people": { + "name": "inspection_people", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_profile_id": { + "name": "role_profile_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_ip_inspection": { + "name": "idx_ip_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "idx_ip_tenant": { + "name": "idx_ip_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_ip_insp_contact_role": { + "name": "uq_ip_insp_contact_role", + "columns": [ + "inspection_id", + "contact_id", + "role_profile_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_requests": { + "name": "inspection_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_phone": { + "name": "client_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_address": { + "name": "property_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "property_city": { + "name": "property_city", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_state": { + "name": "property_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_zip": { + "name": "property_zip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_amount_cents": { + "name": "total_amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unpaid'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_requests_tenant": { + "name": "idx_inspection_requests_tenant", + "columns": [ + "tenant_id", + "status", + "scheduled_at" + ], + "isUnique": false + }, + "idx_inspection_requests_email": { + "name": "idx_inspection_requests_email", + "columns": [ + "tenant_id", + "client_email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspection_requests_tenant_id_tenants_id_fk": { + "name": "inspection_requests_tenant_id_tenants_id_fk", + "tableFrom": "inspection_requests", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_results": { + "name": "inspection_results", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ydoc_state": { + "name": "ydoc_state", + "type": "blob", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating_system_id": { + "name": "rating_system_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rating_system_snapshot": { + "name": "rating_system_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_results_tenant": { + "name": "idx_results_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_results_inspection": { + "name": "idx_results_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "uq_results_inspection": { + "name": "uq_results_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "inspection_results_tenant_id_tenants_id_fk": { + "name": "inspection_results_tenant_id_tenants_id_fk", + "tableFrom": "inspection_results", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_results_inspection_id_inspections_id_fk": { + "name": "inspection_results_inspection_id_inspections_id_fk", + "tableFrom": "inspection_results", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_services": { + "name": "inspection_services", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_id": { + "name": "service_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_override_cents": { + "name": "price_override_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name_snapshot": { + "name": "name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_snapshot_cents": { + "name": "price_snapshot_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_insp_services_tenant": { + "name": "idx_insp_services_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_insp_services_insp": { + "name": "idx_insp_services_insp", + "columns": [ + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspection_services_tenant_id_tenants_id_fk": { + "name": "inspection_services_tenant_id_tenants_id_fk", + "tableFrom": "inspection_services", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_services_inspection_id_inspections_id_fk": { + "name": "inspection_services_inspection_id_inspections_id_fk", + "tableFrom": "inspection_services", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inspection_services_service_id_services_id_fk": { + "name": "inspection_services_service_id_services_id_fk", + "tableFrom": "inspection_services", + "tableTo": "services", + "columnsFrom": [ + "service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_types": { + "name": "inspection_types", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "based_on": { + "name": "based_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_types_tenant_name": { + "name": "idx_inspection_types_tenant_name", + "columns": [ + "tenant_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_units": { + "name": "inspection_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_unit_id": { + "name": "parent_unit_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unit'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "attrs": { + "name": "attrs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_units_tenant_inspection": { + "name": "idx_inspection_units_tenant_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "idx_inspection_units_parent": { + "name": "idx_inspection_units_parent", + "columns": [ + "parent_unit_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspections": { + "name": "inspections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_address": { + "name": "property_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address_place_id": { + "name": "address_place_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_street": { + "name": "address_street", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_city": { + "name": "address_city", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_state": { + "name": "address_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_zip": { + "name": "address_zip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_county": { + "name": "address_county", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_lat": { + "name": "address_lat", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_lng": { + "name": "address_lng", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_geocoded_at": { + "name": "address_geocoded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'requested'" + }, + "report_status": { + "name": "report_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'in_progress'" + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unpaid'" + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancel_notes": { + "name": "cancel_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_payment_required": { + "name": "is_payment_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_agreement_required": { + "name": "is_agreement_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_auto_sign_on_publish": { + "name": "is_auto_sign_on_publish", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "discount_code_id": { + "name": "discount_code_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "discount_amount_cents": { + "name": "discount_amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "closing_date": { + "name": "closing_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referral_source": { + "name": "referral_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_number": { + "name": "reference_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "internal_notes": { + "name": "internal_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "year_built": { + "name": "year_built", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sqft": { + "name": "sqft", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "foundation_type": { + "name": "foundation_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bedrooms": { + "name": "bedrooms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bathrooms": { + "name": "bathrooms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lot_size": { + "name": "lot_size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_facts": { + "name": "property_facts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_photo_id": { + "name": "cover_photo_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_crop": { + "name": "cover_crop", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_image_key": { + "name": "cover_image_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commercial_subtype": { + "name": "commercial_subtype", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report_tier": { + "name": "report_tier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "county": { + "name": "county", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_automations_disabled": { + "name": "is_automations_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "template_snapshot": { + "name": "template_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_snapshot_version": { + "name": "template_snapshot_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "profile_override": { + "name": "profile_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "require_defect_fields_override": { + "name": "require_defect_fields_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "concierge_status": { + "name": "concierge_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_team_mode": { + "name": "is_team_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "lead_inspector_id": { + "name": "lead_inspector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "helper_inspector_ids": { + "name": "helper_inspector_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "data_version": { + "name": "data_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "source_inspection_id": { + "name": "source_inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "root_inspection_id": { + "name": "root_inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reinspection_round": { + "name": "reinspection_round", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit_inspection_mode": { + "name": "unit_inspection_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'tagged'" + }, + "location_options": { + "name": "location_options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sampling_declaration": { + "name": "sampling_declaration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pca_narrative": { + "name": "pca_narrative", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deviations": { + "name": "deviations", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report_photo_mode": { + "name": "report_photo_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_start_ms": { + "name": "scheduled_start_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_end_ms": { + "name": "scheduled_end_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_min": { + "name": "duration_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "badge_layout_override": { + "name": "badge_layout_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report_photo_columns": { + "name": "report_photo_columns", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referred_by_contact_id": { + "name": "referred_by_contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_inspections_tenant": { + "name": "idx_inspections_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_inspections_request": { + "name": "idx_inspections_request", + "columns": [ + "request_id" + ], + "isUnique": false + }, + "idx_inspections_inspector": { + "name": "idx_inspections_inspector", + "columns": [ + "inspector_id" + ], + "isUnique": false + }, + "idx_inspections_tenant_status": { + "name": "idx_inspections_tenant_status", + "columns": [ + "tenant_id", + "status" + ], + "isUnique": false + }, + "idx_inspections_tenant_date": { + "name": "idx_inspections_tenant_date", + "columns": [ + "tenant_id", + "date" + ], + "isUnique": false + }, + "idx_inspections_inspector_date": { + "name": "idx_inspections_inspector_date", + "columns": [ + "inspector_id", + "date" + ], + "isUnique": false + }, + "idx_inspections_root": { + "name": "idx_inspections_root", + "columns": [ + "root_inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspections_tenant_id_tenants_id_fk": { + "name": "inspections_tenant_id_tenants_id_fk", + "tableFrom": "inspections", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_inspector_id_users_id_fk": { + "name": "inspections_inspector_id_users_id_fk", + "tableFrom": "inspections", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_template_id_templates_id_fk": { + "name": "inspections_template_id_templates_id_fk", + "tableFrom": "inspections", + "tableTo": "templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_discount_code_id_discount_codes_id_fk": { + "name": "inspections_discount_code_id_discount_codes_id_fk", + "tableFrom": "inspections", + "tableTo": "discount_codes", + "columnsFrom": [ + "discount_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_request_id_inspection_requests_id_fk": { + "name": "inspections_request_id_inspection_requests_id_fk", + "tableFrom": "inspections", + "tableTo": "inspection_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspector_credentials": { + "name": "inspector_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "member_number": { + "name": "member_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_r2_key": { + "name": "image_r2_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspector_credentials_tenant": { + "name": "idx_inspector_credentials_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_inspector_credentials_user": { + "name": "idx_inspector_credentials_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invoices": { + "name": "invoices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "line_items": { + "name": "line_items", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_at": { + "name": "sent_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_at": { + "name": "paid_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method": { + "name": "payment_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "partial_paid_at": { + "name": "partial_paid_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voided_at": { + "name": "voided_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "qbo_sync_status": { + "name": "qbo_sync_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + } + }, + "indexes": { + "idx_invoices_tenant": { + "name": "idx_invoices_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_invoices_inspection": { + "name": "idx_invoices_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "idx_invoices_contact": { + "name": "idx_invoices_contact", + "columns": [ + "tenant_id", + "contact_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invoices_tenant_id_tenants_id_fk": { + "name": "invoices_tenant_id_tenants_id_fk", + "tableFrom": "invoices", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invoices_inspection_id_inspections_id_fk": { + "name": "invoices_inspection_id_inspections_id_fk", + "tableFrom": "invoices", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invoices_contact_id_contacts_id_fk": { + "name": "invoices_contact_id_contacts_id_fk", + "tableFrom": "invoices", + "tableTo": "contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "marketplace_libraries": { + "name": "marketplace_libraries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "changelog": { + "name": "changelog", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_count": { + "name": "download_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_featured": { + "name": "is_featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_marketplace_libraries_kind_featured": { + "name": "idx_marketplace_libraries_kind_featured", + "columns": [ + "kind", + "is_featured" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "marketplace_templates": { + "name": "marketplace_templates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "changelog": { + "name": "changelog", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_count": { + "name": "download_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_featured": { + "name": "is_featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "message_templates": { + "name": "message_templates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "variables": { + "name": "variables", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_seeded": { + "name": "is_seeded", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_message_templates_tenant_channel": { + "name": "idx_message_templates_tenant_channel", + "columns": [ + "tenant_id", + "channel" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messaging_compliance": { + "name": "messaging_compliance", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'own'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subaccount_sid": { + "name": "subaccount_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_profile_sid": { + "name": "customer_profile_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_profile_status": { + "name": "customer_profile_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "brand_sid": { + "name": "brand_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "brand_status": { + "name": "brand_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "campaign_sid": { + "name": "campaign_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "campaign_status": { + "name": "campaign_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tfv_sid": { + "name": "tfv_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tfv_status": { + "name": "tfv_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "messaging_resource_sid": { + "name": "messaging_resource_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_meta": { + "name": "provider_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provisioned_number": { + "name": "provisioned_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provisioned_number_sid": { + "name": "provisioned_number_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "has_sender_attached": { + "name": "has_sender_attached", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "compliance_status": { + "name": "compliance_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'not_started'" + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_preferences": { + "name": "notification_preferences", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_kind": { + "name": "subject_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "class_id": { + "name": "class_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_notification_prefs_unique": { + "name": "idx_notification_prefs_unique", + "columns": [ + "tenant_id", + "subject_kind", + "subject_id", + "class_id", + "channel" + ], + "isUnique": true + }, + "idx_notification_prefs_subject": { + "name": "idx_notification_prefs_subject", + "columns": [ + "tenant_id", + "subject_kind", + "subject_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "orphaned_media": { + "name": "orphaned_media", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_orphaned_media_key": { + "name": "idx_orphaned_media_key", + "columns": [ + "tenant_id", + "r2_key" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "processed_webhook_events": { + "name": "processed_webhook_events", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "psq_responses": { + "name": "psq_responses", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "responses": { + "name": "responses", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'sent'" + }, + "share_token": { + "name": "share_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_at": { + "name": "sent_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_psq_inspection": { + "name": "uq_psq_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": true + }, + "idx_psq_share_token": { + "name": "idx_psq_share_token", + "columns": [ + "share_token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "qbo_connections": { + "name": "qbo_connections", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "realm_id": { + "name": "realm_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_enc": { + "name": "refresh_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_sync_enabled": { + "name": "is_sync_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "default_item_id": { + "name": "default_item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "qbo_entity_map": { + "name": "qbo_entity_map", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_type": { + "name": "oi_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_id": { + "name": "oi_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qbo_type": { + "name": "qbo_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qbo_id": { + "name": "qbo_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qbo_sync_token": { + "name": "qbo_sync_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "synced_at": { + "name": "synced_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_qbo_entity_map_qbo": { + "name": "idx_qbo_entity_map_qbo", + "columns": [ + "tenant_id", + "qbo_type", + "qbo_id" + ], + "isUnique": true + }, + "idx_qbo_entity_map_oi": { + "name": "idx_qbo_entity_map_oi", + "columns": [ + "tenant_id", + "oi_type", + "oi_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "qbo_sync_errors": { + "name": "qbo_sync_errors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_type": { + "name": "oi_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_id": { + "name": "oi_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_msg": { + "name": "error_msg", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_resolved": { + "name": "is_resolved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rating_systems": { + "name": "rating_systems", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "levels": { + "name": "levels", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_seed": { + "name": "is_seed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_rating_systems_tenant_slug": { + "name": "idx_rating_systems_tenant_slug", + "columns": [ + "tenant_id", + "slug" + ], + "isUnique": true + }, + "idx_rating_systems_tenant": { + "name": "idx_rating_systems_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "rating_systems_tenant_id_tenants_id_fk": { + "name": "rating_systems_tenant_id_tenants_id_fk", + "tableFrom": "rating_systems", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repair_request_items": { + "name": "repair_request_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repair_request_id": { + "name": "repair_request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finding_key": { + "name": "finding_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_title": { + "name": "section_title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_label": { + "name": "item_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "comment_snapshot": { + "name": "comment_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requested_credit_cents": { + "name": "requested_credit_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "defect_title_snapshot": { + "name": "defect_title_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_snapshot": { + "name": "location_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_snapshot": { + "name": "category_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trade_snapshot": { + "name": "trade_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_repair_request_items_rr": { + "name": "idx_repair_request_items_rr", + "columns": [ + "repair_request_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repair_requests": { + "name": "repair_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_kind": { + "name": "created_by_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_ref": { + "name": "created_by_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "custom_intro": { + "name": "custom_intro", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "share_token": { + "name": "share_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_repair_requests_inspection": { + "name": "idx_repair_requests_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "idx_repair_requests_share_token": { + "name": "idx_repair_requests_share_token", + "columns": [ + "share_token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_exports": { + "name": "report_exports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_report_exports_inspection": { + "name": "idx_report_exports_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_pdfs": { + "name": "report_pdfs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rendered_at": { + "name": "rendered_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_version": { + "name": "source_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ready'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_report_pdfs_inspection_type": { + "name": "uq_report_pdfs_inspection_type", + "columns": [ + "inspection_id", + "type", + "version_number" + ], + "isUnique": true + }, + "idx_report_pdfs_tenant": { + "name": "idx_report_pdfs_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_report_pdfs_status": { + "name": "idx_report_pdfs_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_report_pdfs_content_hash": { + "name": "idx_report_pdfs_content_hash", + "columns": [ + "inspection_id", + "type", + "content_hash" + ], + "isUnique": false + } + }, + "foreignKeys": { + "report_pdfs_tenant_id_tenants_id_fk": { + "name": "report_pdfs_tenant_id_tenants_id_fk", + "tableFrom": "report_pdfs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_signoff": { + "name": "report_signoff", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "person_id": { + "name": "person_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "license": { + "name": "license", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "qualifications_ref": { + "name": "qualifications_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_at": { + "name": "signed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signature_ref": { + "name": "signature_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_dual_role": { + "name": "is_dual_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "idx_report_signoff_inspection": { + "name": "idx_report_signoff_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "uq_report_signoff_role": { + "name": "uq_report_signoff_role", + "columns": [ + "inspection_id", + "role" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_versions": { + "name": "report_versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_json": { + "name": "snapshot_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prev_hash": { + "name": "prev_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_fingerprint": { + "name": "key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_amendment": { + "name": "is_amendment", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "idx_report_versions_inspection": { + "name": "idx_report_versions_inspection", + "columns": [ + "inspection_id", + "version_number" + ], + "isUnique": false + }, + "uq_report_versions_inspection_version": { + "name": "uq_report_versions_inspection_version", + "columns": [ + "inspection_id", + "version_number" + ], + "isUnique": true + }, + "idx_report_versions_verify_token": { + "name": "idx_report_versions_verify_token", + "columns": [ + "verification_token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "service_inspectors": { + "name": "service_inspectors", + "columns": { + "service_id": { + "name": "service_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_service_inspectors_tenant": { + "name": "idx_service_inspectors_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "service_inspectors_service_id_user_id_pk": { + "columns": [ + "service_id", + "user_id" + ], + "name": "service_inspectors_service_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "services": { + "name": "services", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agreement_id": { + "name": "agreement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_services_tenant": { + "name": "idx_services_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "services_tenant_id_tenants_id_fk": { + "name": "services_tenant_id_tenants_id_fk", + "tableFrom": "services", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "services_template_id_templates_id_fk": { + "name": "services_template_id_templates_id_fk", + "tableFrom": "services", + "tableTo": "templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "services_agreement_id_agreements_id_fk": { + "name": "services_agreement_id_agreements_id_fk", + "tableFrom": "services", + "tableTo": "agreements", + "columnsFrom": [ + "agreement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "signing_keys": { + "name": "signing_keys", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key_enc": { + "name": "private_key_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key_iv": { + "name": "private_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Ed25519'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "signing_keys_tenant_id_tenants_id_fk": { + "name": "signing_keys_tenant_id_tenants_id_fk", + "tableFrom": "signing_keys", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sms_consent_log": { + "name": "sms_consent_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_type": { + "name": "recipient_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "disclosure_version": { + "name": "disclosure_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "captured_via": { + "name": "captured_via", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_kind": { + "name": "subject_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'contact'" + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + } + }, + "indexes": { + "idx_sms_consent_contact": { + "name": "idx_sms_consent_contact", + "columns": [ + "tenant_id", + "contact_id", + "created_at" + ], + "isUnique": false + }, + "idx_sms_consent_subject": { + "name": "idx_sms_consent_subject", + "columns": [ + "tenant_id", + "subject_kind", + "subject_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sms_delivery_status": { + "name": "sms_delivery_status", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_sms_delivery_status_msg": { + "name": "idx_sms_delivery_status_msg", + "columns": [ + "tenant_id", + "provider_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sms_disclosure_versions": { + "name": "sms_disclosure_versions", + "columns": { + "version": { + "name": "version", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_seed": { + "name": "is_seed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_tags_tenant_name": { + "name": "idx_tags_tenant_name", + "columns": [ + "tenant_id", + "name" + ], + "isUnique": true + }, + "idx_tags_tenant": { + "name": "idx_tags_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "templates": { + "name": "templates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating_system_id": { + "name": "rating_system_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commercial_subtype": { + "name": "commercial_subtype", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_featured": { + "name": "is_featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_profile_id": { + "name": "default_profile_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_templates_tenant": { + "name": "idx_templates_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_templates_rating_system": { + "name": "idx_templates_rating_system", + "columns": [ + "rating_system_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "templates_tenant_id_tenants_id_fk": { + "name": "templates_tenant_id_tenants_id_fk", + "tableFrom": "templates", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_custom_holidays": { + "name": "tenant_custom_holidays", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_tenant_custom_holidays_tenant_date": { + "name": "uq_tenant_custom_holidays_tenant_date", + "columns": [ + "tenant_id", + "date" + ], + "isUnique": true + }, + "idx_tenant_custom_holidays_tenant_date": { + "name": "idx_tenant_custom_holidays_tenant_date", + "columns": [ + "tenant_id", + "date" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_library_imports": { + "name": "tenant_library_imports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_semver": { + "name": "imported_semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_at": { + "name": "imported_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "uq_tenant_library_import": { + "name": "uq_tenant_library_import", + "columns": [ + "tenant_id", + "library_id" + ], + "isUnique": true + }, + "idx_tenant_library_imports_tenant": { + "name": "idx_tenant_library_imports_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_marketplace_import_history": { + "name": "tenant_marketplace_import_history", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_version": { + "name": "source_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_version": { + "name": "target_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rows_affected": { + "name": "rows_affected", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_marketplace_history_tenant": { + "name": "idx_marketplace_history_tenant", + "columns": [ + "tenant_id", + "created_at" + ], + "isUnique": false + }, + "idx_marketplace_history_template": { + "name": "idx_marketplace_history_template", + "columns": [ + "template_id" + ], + "isUnique": false + }, + "idx_marketplace_history_library": { + "name": "idx_marketplace_history_library", + "columns": [ + "library_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_marketplace_imports": { + "name": "tenant_marketplace_imports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "marketplace_template_id": { + "name": "marketplace_template_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_semver": { + "name": "imported_semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_template_id": { + "name": "local_template_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_at": { + "name": "imported_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_mkt_imports_tmpl": { + "name": "idx_mkt_imports_tmpl", + "columns": [ + "marketplace_template_id" + ], + "isUnique": false + }, + "idx_mkt_imports_tenant": { + "name": "idx_mkt_imports_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk": { + "name": "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk", + "tableFrom": "tenant_marketplace_imports", + "tableTo": "marketplace_templates", + "columnsFrom": [ + "marketplace_template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tenant_marketplace_imports_local_template_id_templates_id_fk": { + "name": "tenant_marketplace_imports_local_template_id_templates_id_fk", + "tableFrom": "tenant_marketplace_imports", + "tableTo": "templates", + "columnsFrom": [ + "local_template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_counters": { + "name": "usage_counters", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period_key": { + "name": "period_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_usage_counters_tenant": { + "name": "idx_usage_counters_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_counters_tenant_id_metric_period_key_pk": { + "columns": [ + "tenant_id", + "metric", + "period_key" + ], + "name": "usage_counters_tenant_id_metric_period_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_templates": { + "name": "email_templates", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "blocks": { + "name": "blocks", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "email_templates_tenant_id_tenants_id_fk": { + "name": "email_templates_tenant_id_tenants_id_fk", + "tableFrom": "email_templates", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_templates_tenant_id_trigger_pk": { + "columns": [ + "tenant_id", + "trigger" + ], + "name": "email_templates_tenant_id_trigger_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_configs": { + "name": "tenant_configs", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "support_email": { + "name": "support_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_address": { + "name": "company_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_pdf_footer_shown": { + "name": "is_pdf_footer_shown", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_pdf_page_numbers_shown": { + "name": "is_pdf_page_numbers_shown", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_pdf_license_shown": { + "name": "is_pdf_license_shown", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sender_email": { + "name": "sender_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reply_to": { + "name": "reply_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_mode": { + "name": "email_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'platform'" + }, + "video_mode": { + "name": "video_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'r2'" + }, + "sms_mode": { + "name": "sms_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'platform'" + }, + "sender_display_name": { + "name": "sender_display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "point_of_contact": { + "name": "point_of_contact", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'company'" + }, + "billing_url": { + "name": "billing_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_url": { + "name": "review_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_phone": { + "name": "company_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "integration_config": { + "name": "integration_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secrets_enc": { + "name": "secrets_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dek_enc": { + "name": "dek_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ics_token": { + "name": "ics_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "widget_allowed_origins": { + "name": "widget_allowed_origins", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_profile_id": { + "name": "default_profile_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'signature'" + }, + "attention_thresholds": { + "name": "attention_thresholds", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"agreement_unsigned_h\":72,\"invoice_overdue_h\":72,\"report_unpublished_h\":72}'" + }, + "inspection_prefs": { + "name": "inspection_prefs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_estimates_shown": { + "name": "is_estimates_shown", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_repair_list_enabled": { + "name": "is_repair_list_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_customer_repair_export_enabled": { + "name": "is_customer_repair_export_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_unpaid_blocked": { + "name": "is_unpaid_blocked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_unsigned_agreement_blocked": { + "name": "is_unsigned_agreement_blocked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "custom_referral_sources": { + "name": "custom_referral_sources", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dashboard_column_prefs": { + "name": "dashboard_column_prefs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_concierge_review_required": { + "name": "is_concierge_review_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_inspector_choice_allowed": { + "name": "is_inspector_choice_allowed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_pdf_pipeline_enabled": { + "name": "is_pdf_pipeline_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_team_mode_default": { + "name": "is_team_mode_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_apprentice_review_required": { + "name": "is_apprentice_review_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_guest_invites_enabled": { + "name": "is_guest_invites_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "require_defect_fields": { + "name": "require_defect_fields", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "agreement_retention_years": { + "name": "agreement_retention_years", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 6 + }, + "reinspection_statuses": { + "name": "reinspection_statuses", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_collab_editing_enabled": { + "name": "is_collab_editing_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sms_byo_provider": { + "name": "sms_byo_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_byo_provider": { + "name": "email_byo_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'resend'" + }, + "is_managed_eligible": { + "name": "is_managed_eligible", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "managed_provider": { + "name": "managed_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'twilio'" + }, + "is_reserve_schedule_enabled": { + "name": "is_reserve_schedule_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "reserve_term_years": { + "name": "reserve_term_years", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 12 + }, + "inflation_rate_bps": { + "name": "inflation_rate_bps", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_timezone": { + "name": "default_timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'UTC'" + }, + "booking_slot_mode": { + "name": "booking_slot_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'fixed'" + }, + "booking_slot_interval_min": { + "name": "booking_slot_interval_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "holiday_region": { + "name": "holiday_region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "holiday_public_policy": { + "name": "holiday_public_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "holiday_internal_policy": { + "name": "holiday_internal_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'advisory'" + }, + "default_locale": { + "name": "default_locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en-US'" + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "is_archive_revoking_access": { + "name": "is_archive_revoking_access", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "legal_mode": { + "name": "legal_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'hosted'" + }, + "custom_privacy_url": { + "name": "custom_privacy_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_terms_url": { + "name": "custom_terms_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "privacy_body": { + "name": "privacy_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terms_body": { + "name": "terms_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_configs_tenant_id_tenants_id_fk": { + "name": "tenant_configs_tenant_id_tenants_id_fk", + "tableFrom": "tenant_configs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenants": { + "name": "tenants", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'free'" + }, + "stripe_connect_account_id": { + "name": "stripe_connect_account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "max_users": { + "name": "max_users", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 5 + }, + "deployment_mode": { + "name": "deployment_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'shared'" + }, + "applied_cmd_seq": { + "name": "applied_cmd_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "applied_cred_seq": { + "name": "applied_cred_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tenants_slug_unique": { + "name": "tenants_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_invites": { + "name": "tenant_invites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'inspector'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mentor_id": { + "name": "mentor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assigned_section_ids": { + "name": "assigned_section_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "permission_overrides": { + "name": "permission_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_invites_tenant": { + "name": "idx_invites_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_tenant_invites_pending_email": { + "name": "uq_tenant_invites_pending_email", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": true, + "where": "status = 'pending'" + } + }, + "foreignKeys": { + "tenant_invites_tenant_id_tenants_id_fk": { + "name": "tenant_invites_tenant_id_tenants_id_fk", + "tableFrom": "tenant_invites", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "license_number": { + "name": "license_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_signature_base64": { + "name": "default_signature_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_signature_enabled": { + "name": "is_signature_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manager'" + }, + "onboarding_state": { + "name": "onboarding_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_totp_enabled": { + "name": "is_totp_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "totp_recovery_codes": { + "name": "totp_recovery_codes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totp_verified_at": { + "name": "totp_verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mentor_id": { + "name": "mentor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assigned_section_ids": { + "name": "assigned_section_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terms_accepted": { + "name": "terms_accepted", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permission_overrides": { + "name": "permission_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + "deleted_at" + ], + "isUnique": false + }, + "uq_users_tenant_email": { + "name": "uq_users_tenant_email", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": true, + "where": "deleted_at IS NULL" + }, + "idx_users_tenant": { + "name": "idx_users_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_users_slug_per_tenant": { + "name": "idx_users_slug_per_tenant", + "columns": [ + "tenant_id", + "slug" + ], + "isUnique": true + }, + "idx_users_email": { + "name": "idx_users_email", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "users_tenant_id_tenants_id_fk": { + "name": "users_tenant_id_tenants_id_fk", + "tableFrom": "users", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_slug": { + "name": "inspector_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_audit_tenant_created": { + "name": "idx_audit_tenant_created", + "columns": [ + "tenant_id", + "created_at" + ], + "isUnique": false + }, + "idx_audit_entity": { + "name": "idx_audit_entity", + "columns": [ + "entity_type", + "entity_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_logs_tenant_id_tenants_id_fk": { + "name": "audit_logs_tenant_id_tenants_id_fk", + "tableFrom": "audit_logs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration_test_results": { + "name": "integration_test_results", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_ok": { + "name": "is_ok", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tested_by_user_id": { + "name": "tested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tested_at": { + "name": "tested_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_integration_test_tenant_target": { + "name": "idx_integration_test_tenant_target", + "columns": [ + "tenant_id", + "target", + "tested_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_notifications_tenant_user_created": { + "name": "idx_notifications_tenant_user_created", + "columns": [ + "tenant_id", + "user_id", + "created_at" + ], + "isUnique": false + }, + "idx_notifications_tenant_user_unread": { + "name": "idx_notifications_tenant_user_unread", + "columns": [ + "tenant_id", + "user_id", + "read_at" + ], + "isUnique": false + }, + "idx_notifications_tenant_contact_created": { + "name": "idx_notifications_tenant_contact_created", + "columns": [ + "tenant_id", + "contact_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notifications_tenant_id_tenants_id_fk": { + "name": "notifications_tenant_id_tenants_id_fk", + "tableFrom": "notifications", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "parked_cmd_events": { + "name": "parked_cmd_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "envelope": { + "name": "envelope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_parked_cmd_events_received_at": { + "name": "idx_parked_cmd_events_received_at", + "columns": [ + "received_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "processed_cmd_events": { + "name": "processed_cmd_events", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cmd_type": { + "name": "cmd_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "processed_at": { + "name": "processed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "slug_reservations": { + "name": "slug_reservations", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_outbox": { + "name": "sync_outbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_tried_at": { + "name": "last_tried_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_sync_outbox_status_created": { + "name": "idx_sync_outbox_status_created", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_destruction_records": { + "name": "tenant_destruction_records", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_slug": { + "name": "tenant_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rows_deleted": { + "name": "rows_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "r2_objects": { + "name": "r2_objects", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "r2_bytes": { + "name": "r2_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "kv_keys": { + "name": "kv_keys", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "destroyed_at": { + "name": "destroyed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_destruction_tenant": { + "name": "idx_destruction_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_destruction_destroyed_at": { + "name": "idx_destruction_destroyed_at", + "columns": [ + "destroyed_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_legal_versions": { + "name": "tenant_legal_versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "doc": { + "name": "doc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body_snapshot": { + "name": "body_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_material": { + "name": "is_material", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_by_user_id": { + "name": "published_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_tenant_legal_versions_doc_version": { + "name": "idx_tenant_legal_versions_doc_version", + "columns": [ + "tenant_id", + "doc", + "version" + ], + "isUnique": true + }, + "idx_tenant_legal_versions_latest": { + "name": "idx_tenant_legal_versions_latest", + "columns": [ + "tenant_id", + "doc", + "published_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "client_uploads": { + "name": "client_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_kind": { + "name": "uploaded_by_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_ref": { + "name": "uploaded_by_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_name": { + "name": "uploaded_by_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_client_uploads_inspection": { + "name": "idx_client_uploads_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "uq_discount_codes_code_tenant": { + "columns": { + "upper(code)": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 2f9ba38c7..4293c8df6 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1785502662839, "tag": "0020_fancy_toxin", "breakpoints": true + }, + { + "idx": 21, + "version": "6", + "when": 1785515513924, + "tag": "0021_gigantic_bruce_banner", + "breakpoints": true } ] } \ No newline at end of file diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index b3a97f7c7..5015fc547 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -11,11 +11,11 @@ "app/components/portal/sections/ReportView.tsx": 806, "app/routes/settings-communication.tsx": 777, "server/services/inspection.service.ts": 755, + "server/api/admin/admin-settings.ts": 742, "server/api/inspections/report-delivery.ts": 736, "app/routes/settings-communication-templates.tsx": 731, "server/services/inspection/inspection-analytics.service.ts": 727, "app/routes/template-edit.tsx": 719, - "server/api/admin/admin-settings.ts": 713, "server/index.ts": 693, "app/components/media-studio/PhotoAnnotator.tsx": 692, "app/hooks/usePhotoOps.ts": 661, @@ -60,7 +60,7 @@ "app/routes/public/portal-inspection.tsx": 430, "server/api/inspections/results.ts": 430, "app/hooks/useStructureEdit.ts": 424, - "server/lib/middleware/di.ts": 417, + "server/lib/middleware/di.ts": 422, "app/routes/templates.tsx": 414, "app/routes/calendar.tsx": 410, "app/lib/section-loaders.ts": 402 diff --git a/server/api/admin/admin-settings.ts b/server/api/admin/admin-settings.ts index 4d1e29f1b..605d81ef8 100644 --- a/server/api/admin/admin-settings.ts +++ b/server/api/admin/admin-settings.ts @@ -18,6 +18,7 @@ import { requireRole } from '../../lib/middleware/rbac'; import { auditFromContext } from '../../lib/audit'; import { getBaseUrl, resolveTenantSlug } from '../../lib/url'; import { Errors } from '../../lib/errors'; +import { logger } from '../../lib/logger'; import { resolveTenantLegalUrls, type LegalMode } from '../../lib/legal-links'; import { AttentionThresholdsSchema, @@ -611,6 +612,34 @@ const adminSettingsRoutes = createApiRouter() return c.json({ success: true as const, data: { ok: true as const } }, 200); } await c.var.services.branding.updateBranding(tenantId, update); + + // A publish is recorded AFTER the write succeeds, and only for a body + // that actually changed (the service compares the content hash and + // no-ops on a match). Recording before the write would register a + // version of text that may never have gone live; recording on every + // PATCH would mint a version each time a tenant saved an unrelated + // setting, and a registry that grows a row per form submission stops + // meaning "the document changed" almost immediately. + // + // Non-fatal by design: the version row is evidence about a save, not + // part of it. Failing the settings save because the historian fell over + // would lose the change the tenant actually asked for. + for (const doc of ['privacy', 'terms'] as const) { + const key = doc === 'privacy' ? 'privacyBody' : 'termsBody'; + if (update[key] === undefined) continue; + try { + await c.var.services.legalVersion.recordPublish({ + tenantId, + doc, + body: (update[key] as string | null) ?? null, + userId: c.get('user')?.sub ?? null, + }); + } catch (err) { + logger.error('[legal] failed to record a published version', { doc }, + err instanceof Error ? err : undefined); + } + } + auditFromContext(c, 'config.tenant_config.patch', 'tenant_config', { metadata: update, }); diff --git a/server/api/public/inspector-profile.ts b/server/api/public/inspector-profile.ts index 04c42543d..753aa5e40 100644 --- a/server/api/public/inspector-profile.ts +++ b/server/api/public/inspector-profile.ts @@ -42,6 +42,7 @@ const legalDocRoute = createRoute(withMcpMetadata({ schema: createApiResponseSchema(z.object({ companyName: z.string(), body: z.string().nullable().describe('Custom body when set; null = use built-in template'), + lastUpdated: z.string().nullable().describe('Version of the document currently in force (YYYY-MM-DD in the tenant timezone), or null when nothing has been published yet.'), })), }, }, @@ -100,7 +101,15 @@ const publicInspectorProfileRoutes = createApiRouter() const body = doc === 'privacy' ? (cfg?.privacyBody?.trim() || null) : (cfg?.termsBody?.trim() || null); - return c.json({ success: true as const, data: { companyName, body } }, 200); + // "Last updated" comes from the version REGISTRY, never from the config + // row's own timestamp: the registry is the only thing that knows a save + // changed the text rather than some neighbouring setting. Null until the + // tenant has published once, and the page then says nothing rather than + // inventing a date. + const latest = await c.var.services.legalVersion.latest(row.id, doc); + return c.json({ success: true as const, data: { + companyName, body, lastUpdated: latest?.version ?? null, + } }, 200); }) .openapi(brandAssetRoute, async (c) => { const { key } = c.req.valid('query'); diff --git a/server/lib/compliance/erasure-manifest.ts b/server/lib/compliance/erasure-manifest.ts index 6fa5631b3..ff428d71b 100644 --- a/server/lib/compliance/erasure-manifest.ts +++ b/server/lib/compliance/erasure-manifest.ts @@ -195,4 +195,12 @@ export const ERASURE_OUT_OF_SCOPE: ErasureOutOfScopeEntry[] = [ { table: 'contact_role_profiles', column: 'email_template_id', reason: 'template reference, not personal data' }, { table: 'sms_consent_log', column: 'recipient_type', reason: 'role-kind enum, not personal data' }, { table: 'report_versions', column: 'signature', reason: 'report-content integrity seal, not personal data' }, + // The tenant's own published Privacy / Terms. `body_snapshot` is the + // company's prose, not a data subject's data, and the row's whole purpose is + // to be immutable — erasing it would destroy the record of what a document + // said at a date, which is the one thing it exists to answer. Listed rather + // than left silent because the PII heuristic does not flag any column here, + // and silence is not the same as a decision. + { table: 'tenant_legal_versions', column: 'body_snapshot', reason: "the company's own published policy text, not a data subject's data" }, + { table: 'tenant_legal_versions', column: 'published_by_user_id', reason: 'staff author reference — not consumer-DSAR scope' }, ]; diff --git a/server/lib/db/schema/tenant/index.ts b/server/lib/db/schema/tenant/index.ts index dd013fb45..054d62ee4 100644 --- a/server/lib/db/schema/tenant/index.ts +++ b/server/lib/db/schema/tenant/index.ts @@ -1,3 +1,4 @@ export * from './core'; export * from './user'; export * from './integration'; +export * from './legal-versions'; diff --git a/server/lib/db/schema/tenant/legal-versions.ts b/server/lib/db/schema/tenant/legal-versions.ts new file mode 100644 index 000000000..f65b30bd7 --- /dev/null +++ b/server/lib/db/schema/tenant/legal-versions.ts @@ -0,0 +1,58 @@ +import { sqliteTable, text, integer, index, uniqueIndex } from 'drizzle-orm/sqlite-core'; + +/** + * An immutable record of what a tenant's Privacy Policy and Terms actually SAID, + * each time they changed. + * + * WHY THIS STORES THE BODY AND NOT JUST A HASH. The platform's own documents + * live in a repository, so a version registry there can hash the text and let + * git hold it. A tenant's `tenant_configs.privacy_body` is a mutable TEXT column + * that the next save overwrites, with nothing behind it. A hash-only row would + * therefore prove that the text changed while being unable to produce the text + * that changed — failing at exactly the moment somebody needs it. The shape here + * is copied from this codebase's own agreement envelope + * (`agreement_requests.content_snapshot` / `.content_hash`), which solved the + * same problem for the same reason. + * + * WHAT A ROW MEANS. One publish of one document. `version` is a DATE STRING and + * the inspection Agreement's is an auto-increment integer; the formats differ on + * purpose, so a reader can never mistake one object for the other. They share no + * table, no counter and no acceptance flow — a company policy is one per tenant + * and shown in a footer, while an Agreement is N per order, signed by named + * parties, and gates the report. + * + * SAME-DAY REPUBLISH COLLAPSES, and that is a property rather than an accident: + * `(tenant, doc, version)` is unique, so several saves on one date leave the row + * that ENDED that date — which is the text that was actually in force when the + * date closed. Nothing depends on the intermediate ones, because OI records no + * acceptance against these documents (re-acceptance is deliberately not built: + * a client has no account, so the only place to interrupt them is the report + * path that already carries a pay-gate and a sign-gate). + * + * `is_material` is recorded from day one even though nothing reads it yet, so + * that per-tenant, material-only re-acceptance stays possible without a + * backfill that would have to guess. + */ +export const tenantLegalVersions = sqliteTable('tenant_legal_versions', { + id: text('id').primaryKey(), + tenantId: text('tenant_id').notNull(), + doc: text('doc', { enum: ['privacy', 'terms'] }).notNull(), + /** `YYYY-MM-DD` in the tenant's own timezone — the date a reader is shown. */ + version: text('version').notNull(), + /** + * The document body as published. NULL means the tenant cleared their + * override and reverted to the built-in template — which is a publish, and + * is recorded as one, because "they went back to the default" is exactly the + * kind of change a missing row would silently hide. + */ + bodySnapshot: text('body_snapshot'), + /** SHA-256 hex of `bodySnapshot` (of the empty string when it is NULL). */ + contentHash: text('content_hash').notNull(), + isMaterial: integer('is_material', { mode: 'boolean' }).notNull().default(false), + publishedAt: integer('published_at', { mode: 'timestamp_ms' }).notNull(), + /** The staff user who saved it; NULL for a system-originated publish. */ + publishedByUserId: text('published_by_user_id'), +}, (t) => [ + uniqueIndex('idx_tenant_legal_versions_doc_version').on(t.tenantId, t.doc, t.version), + index('idx_tenant_legal_versions_latest').on(t.tenantId, t.doc, t.publishedAt), +]); diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json index ee46a2631..7c2f8120a 100644 --- a/server/lib/mcp/openapi-snapshot.json +++ b/server/lib/mcp/openapi-snapshot.json @@ -15001,6 +15001,14 @@ "type": "string", "format": "email", "description": "Recipient email address requesting a portal magic-link." + }, + "destination": { + "type": "string", + "enum": [ + "portal", + "notifications" + ], + "description": "Where the magic-link should land. `portal` (default) = the inspections list; `notifications` = the notification settings page, for a reader arriving from the privacy policy or terms." } }, "required": [ diff --git a/server/lib/middleware/di.ts b/server/lib/middleware/di.ts index afcae2e15..55334a663 100644 --- a/server/lib/middleware/di.ts +++ b/server/lib/middleware/di.ts @@ -11,6 +11,8 @@ import { OutboxService } from '../../portal/outbox.service'; import { publishRow } from '../../portal/outbox.service'; import { BookingService } from '../../services/booking.service'; import { BrandingService } from '../../services/branding.service'; +import { LegalVersionService } from '../../services/legal-version.service'; +import { drizzle } from 'drizzle-orm/d1'; import { assembleTenantEmailService, loadTenantEmailConfig, type LoadedEmailConfig } from '../email/build-email-service'; import { InspectionService } from '../../services/inspection.service'; import type { ImagesBinding } from '../media/strip-exif'; @@ -192,6 +194,9 @@ export async function diMiddleware(c: Context, next: Next) { case 'branding': target.branding = new BrandingService(c.env.DB, c.env.TENANT_CACHE); break; + case 'legalVersion': + target.legalVersion = new LegalVersionService(drizzle(c.env.DB)); + break; case 'email': target.email = buildEmailService(); break; diff --git a/server/lib/tz.ts b/server/lib/tz.ts index 9ab0a9d58..2699a7288 100644 --- a/server/lib/tz.ts +++ b/server/lib/tz.ts @@ -75,6 +75,17 @@ export function epochMsToWallClockHm(ms: number, ianaTz: string): string { return epochMsToRfc3339(ms, ianaTz).slice(11, 16); } +/** + * Wall-clock `YYYY-MM-DD` of instant `ms` in `ianaTz`. + * + * Not `new Date(ms).toISOString().slice(0, 10)`: that is the UTC date, which is + * the wrong day for roughly a third of every day west of Greenwich. Anything a + * reader is shown as "the date this happened" has to be their date. + */ +export function epochMsToWallClockYmd(ms: number, ianaTz: string): string { + return epochMsToRfc3339(ms, ianaTz).slice(0, 10); +} + /** `YYYY-MM-DD` + `HH:MM` interpreted as local wall-clock in `ianaTz` -> UTC epoch ms. */ export function wallClockToEpochMs(dateYmd: string, timeHm: string, ianaTz: string): number { const [y, mo, d] = dateYmd.split('-').map(Number); diff --git a/server/services/legal-version.service.ts b/server/services/legal-version.service.ts new file mode 100644 index 000000000..2f8e0f1b4 --- /dev/null +++ b/server/services/legal-version.service.ts @@ -0,0 +1,137 @@ +import { and, desc, eq } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import type * as schema from '../lib/db/schema'; +import { tenantLegalVersions, tenantConfigs } from '../lib/db/schema'; +import { sha256Hex } from './signing-key.service'; +import { epochMsToWallClockYmd, resolveTenantTimeZone } from '../lib/tz'; + +export type LegalDoc = 'privacy' | 'terms'; + +export interface LegalVersionRow { + version: string; + publishedAt: Date; + contentHash: string; + isMaterial: boolean; + bodySnapshot: string | null; +} + +/** + * Versioning for a tenant's own Privacy Policy and Terms (design §6A.3). + * + * The whole point is that a row can PRODUCE the text it describes, so + * `recordPublish` snapshots the body rather than hashing it and moving on — + * see the schema comment for why the platform's own registry can get away with + * hashing and this one cannot. + */ +export class LegalVersionService { + constructor(private readonly db: DrizzleD1Database) {} + + /** + * Record a publish, unless nothing was published. + * + * A tenant saving unrelated settings, or re-saving the same prose, must not + * mint a version — a registry that grows a row per form submission stops + * meaning "the document changed" on its first busy afternoon. So the + * content hash is compared against the latest row and an unchanged body is + * a no-op that returns the existing version. + * + * Returns the version string now in force, or null when the write failed. + */ + async recordPublish(input: { + tenantId: string; + doc: LegalDoc; + body: string | null; + /** Test seam only — production resolves it from the tenant's config. */ + timezone?: string | null; + userId?: string | null; + isMaterial?: boolean; + now?: number; + }): Promise { + const body = input.body?.trim() ? input.body : null; + const contentHash = await sha256Hex(body ?? ''); + + const latest = await this.latest(input.tenantId, input.doc); + if (latest && latest.contentHash === contentHash) return latest.version; + + const at = input.now ?? Date.now(); + // The date a reader is shown must be the TENANT's date, so the service + // resolves it rather than trusting a caller to pass one. `2026-08-01` in + // UTC is still 2026-07-31 across the Americas for most of the day, and a + // "last updated" that is a day ahead of the company's own calendar is + // the kind of wrong that only ever shows up in a complaint. + const timezone = input.timezone ?? await this.tenantTimezone(input.tenantId); + const version = epochMsToWallClockYmd(at, resolveTenantTimeZone(timezone)); + + // Same-day republish REPLACES rather than appending: the row that + // survives a date is the text that was in force when that date closed, + // which is the only one anything downstream could have relied on. The + // unique index is what makes that true rather than a convention. + await this.db.insert(tenantLegalVersions).values({ + id: crypto.randomUUID(), + tenantId: input.tenantId, + doc: input.doc, + version, + bodySnapshot: body, + contentHash, + isMaterial: input.isMaterial ?? false, + publishedAt: new Date(at), + publishedByUserId: input.userId ?? null, + }).onConflictDoUpdate({ + target: [tenantLegalVersions.tenantId, tenantLegalVersions.doc, tenantLegalVersions.version], + set: { + bodySnapshot: body, + contentHash, + isMaterial: input.isMaterial ?? false, + publishedAt: new Date(at), + publishedByUserId: input.userId ?? null, + }, + }); + return version; + } + + private async tenantTimezone(tenantId: string): Promise { + const row = await this.db.select({ defaultTimezone: tenantConfigs.defaultTimezone }) + .from(tenantConfigs) + .where(eq(tenantConfigs.tenantId, tenantId)) + .get(); + return row?.defaultTimezone ?? null; + } + + /** The version currently in force, or null when this doc was never published. */ + async latest(tenantId: string, doc: LegalDoc): Promise { + const row = await this.db.select({ + version: tenantLegalVersions.version, + publishedAt: tenantLegalVersions.publishedAt, + contentHash: tenantLegalVersions.contentHash, + isMaterial: tenantLegalVersions.isMaterial, + bodySnapshot: tenantLegalVersions.bodySnapshot, + }) + .from(tenantLegalVersions) + .where(and( + eq(tenantLegalVersions.tenantId, tenantId), + eq(tenantLegalVersions.doc, doc), + )) + .orderBy(desc(tenantLegalVersions.publishedAt)) + .limit(1) + .get(); + return row ?? null; + } + + /** Every version of one document, newest first. */ + async list(tenantId: string, doc: LegalDoc): Promise { + return this.db.select({ + version: tenantLegalVersions.version, + publishedAt: tenantLegalVersions.publishedAt, + contentHash: tenantLegalVersions.contentHash, + isMaterial: tenantLegalVersions.isMaterial, + bodySnapshot: tenantLegalVersions.bodySnapshot, + }) + .from(tenantLegalVersions) + .where(and( + eq(tenantLegalVersions.tenantId, tenantId), + eq(tenantLegalVersions.doc, doc), + )) + .orderBy(desc(tenantLegalVersions.publishedAt)) + .all(); + } +} diff --git a/server/types/hono.ts b/server/types/hono.ts index 083759bae..1ed3ce74a 100644 --- a/server/types/hono.ts +++ b/server/types/hono.ts @@ -224,6 +224,7 @@ import type { AuthService } from '../services/auth.service'; import type { UserSyncOutbox } from '../lib/integration/user-sync'; import type { BookingService, AvailabilityService } from '../services/booking.service'; import type { BrandingService } from '../services/branding.service'; +import type { LegalVersionService } from '../services/legal-version.service'; import type { EmailService } from '../services/email.service'; import type { InspectionService } from '../services/inspection.service'; import type { TeamService } from '../services/team.service'; @@ -273,6 +274,8 @@ export interface AppServices { outbox?: UserSyncOutbox | undefined; booking: BookingService; branding: BrandingService; + /** Immutable version rows for the tenant's own Privacy / Terms (design 6A.3). */ + legalVersion: LegalVersionService; email: EmailService; inspection: InspectionService; team: TeamService; diff --git a/tests/unit/legal/legal-version.service.spec.ts b/tests/unit/legal/legal-version.service.spec.ts new file mode 100644 index 000000000..b31e34866 --- /dev/null +++ b/tests/unit/legal/legal-version.service.spec.ts @@ -0,0 +1,132 @@ +/** + * Versioning a tenant's own Privacy / Terms (design §6A.3). + * + * The requirement is narrow and easy to satisfy wrongly: a row per publish that + * can PRODUCE the text it describes. The three ways to get that wrong, all + * pinned below, are (a) hashing without snapshotting, (b) minting a version + * every time somebody saves the settings form, and (c) computing the date in + * UTC so a company west of Greenwich reads tomorrow's date on their own policy. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createTestDb, setupSchema } from '../db'; +import * as schema from '../../../server/lib/db/schema'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { LegalVersionService } from '../../../server/services/legal-version.service'; + +const TENANT = 'tenant-1'; + +describe('LegalVersionService', () => { + let db: BetterSQLite3Database; + let svc: LegalVersionService; + + beforeEach(async () => { + const fix = createTestDb(); + db = fix.db; + await setupSchema(fix.sqlite); + await db.insert(schema.tenants).values({ + id: TENANT, name: 'Acme', slug: 'acme', status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + }); + await db.insert(schema.tenantConfigs).values({ + tenantId: TENANT, companyName: 'Acme', defaultTimezone: 'America/Los_Angeles', + updatedAt: new Date(), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + svc = new LegalVersionService(db as any); + }); + + it('stores the BODY, not only a hash of it', async () => { + await svc.recordPublish({ tenantId: TENANT, doc: 'privacy', body: 'We collect nothing.' }); + const latest = await svc.latest(TENANT, 'privacy'); + // The whole reason this table exists rather than a hash registry: the + // source column is mutable with no git behind it, so a row that cannot + // reproduce the text proves a change it cannot show. + expect(latest?.bodySnapshot).toBe('We collect nothing.'); + expect(latest?.contentHash).toMatch(/^[0-9a-f]{64}$/); + }); + + it('keeps the OLD text after the source column is overwritten', async () => { + await svc.recordPublish({ tenantId: TENANT, doc: 'privacy', body: 'First text.', now: Date.parse('2026-06-01T12:00:00Z') }); + await svc.recordPublish({ tenantId: TENANT, doc: 'privacy', body: 'Second text.', now: Date.parse('2026-06-02T12:00:00Z') }); + + const all = await svc.list(TENANT, 'privacy'); + expect(all.map((r) => r.bodySnapshot)).toEqual(['Second text.', 'First text.']); + expect(all.map((r) => r.version)).toEqual(['2026-06-02', '2026-06-01']); + }); + + it('does NOT mint a version when the text did not change', async () => { + const at = Date.parse('2026-06-01T12:00:00Z'); + await svc.recordPublish({ tenantId: TENANT, doc: 'privacy', body: 'Same.', now: at }); + // A tenant saving an unrelated setting re-sends the same body. A + // registry that grows a row per form submission stops meaning "the + // document changed" on its first busy afternoon. + const second = await svc.recordPublish({ + tenantId: TENANT, doc: 'privacy', body: 'Same.', now: at + 86_400_000, + }); + expect(second).toBe('2026-06-01'); + expect(await svc.list(TENANT, 'privacy')).toHaveLength(1); + }); + + it('treats whitespace-only and null as the same "reverted to the template" publish', async () => { + await svc.recordPublish({ tenantId: TENANT, doc: 'terms', body: 'Custom.', now: Date.parse('2026-06-01T12:00:00Z') }); + await svc.recordPublish({ tenantId: TENANT, doc: 'terms', body: null, now: Date.parse('2026-06-02T12:00:00Z') }); + await svc.recordPublish({ tenantId: TENANT, doc: 'terms', body: ' ', now: Date.parse('2026-06-03T12:00:00Z') }); + + const all = await svc.list(TENANT, 'terms'); + // Clearing the override IS a publish and is recorded as one — "they went + // back to the default" is exactly what a missing row would hide. But the + // second clearing changed nothing, so it does not add a third row. + expect(all).toHaveLength(2); + expect(all[0].bodySnapshot).toBeNull(); + expect(all[0].version).toBe('2026-06-02'); + }); + + it('dates the version in the TENANT timezone, not UTC', async () => { + // 2026-06-02T04:00Z is still June 1st in Los Angeles. A UTC date here + // would show a company a "last updated" one day ahead of their own + // calendar, which is the kind of wrong that only surfaces in a complaint. + const version = await svc.recordPublish({ + tenantId: TENANT, doc: 'privacy', body: 'Evening save.', + now: Date.parse('2026-06-02T04:00:00Z'), + }); + expect(version).toBe('2026-06-01'); + }); + + it('collapses same-day republishes onto the text that ENDED the day', async () => { + const morning = Date.parse('2026-06-01T16:00:00Z'); // 09:00 LA + const evening = Date.parse('2026-06-02T01:00:00Z'); // 18:00 LA, same day + await svc.recordPublish({ tenantId: TENANT, doc: 'privacy', body: 'Morning.', now: morning }); + await svc.recordPublish({ tenantId: TENANT, doc: 'privacy', body: 'Evening.', now: evening }); + + const all = await svc.list(TENANT, 'privacy'); + expect(all).toHaveLength(1); + expect(all[0].version).toBe('2026-06-01'); + expect(all[0].bodySnapshot).toBe('Evening.'); + }); + + it('keeps privacy and terms on separate tracks', async () => { + await svc.recordPublish({ tenantId: TENANT, doc: 'privacy', body: 'P', now: Date.parse('2026-06-01T12:00:00Z') }); + await svc.recordPublish({ tenantId: TENANT, doc: 'terms', body: 'T', now: Date.parse('2026-06-05T12:00:00Z') }); + expect((await svc.latest(TENANT, 'privacy'))?.version).toBe('2026-06-01'); + expect((await svc.latest(TENANT, 'terms'))?.version).toBe('2026-06-05'); + }); + + it('reports no version at all before the first publish', async () => { + // The page then says nothing rather than inventing a date. + expect(await svc.latest(TENANT, 'privacy')).toBeNull(); + }); + + it('never lets one tenant read another tenant version', async () => { + await db.insert(schema.tenants).values({ + id: 'tenant-2', name: 'Other', slug: 'other', status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + }); + await svc.recordPublish({ tenantId: TENANT, doc: 'privacy', body: 'Acme text.' }); + expect(await svc.latest('tenant-2', 'privacy')).toBeNull(); + }); + + it('records is_material so opt-in re-acceptance stays possible without a backfill', async () => { + await svc.recordPublish({ tenantId: TENANT, doc: 'privacy', body: 'Big change.', isMaterial: true }); + expect((await svc.latest(TENANT, 'privacy'))?.isMaterial).toBe(true); + }); +}); diff --git a/tests/unit/messaging/compliance-settings-api.spec.ts b/tests/unit/messaging/compliance-settings-api.spec.ts index 30a6cba6b..b0a26cb02 100644 --- a/tests/unit/messaging/compliance-settings-api.spec.ts +++ b/tests/unit/messaging/compliance-settings-api.spec.ts @@ -27,6 +27,7 @@ import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; // Import admin routes AFTER the mock is set up. // eslint-disable-next-line import/order import adminRoutes from '../../../server/api/admin'; +import { LegalVersionService } from '../../../server/services/legal-version.service'; const TENANT_ID = 'aaaaaaaa-0000-0000-0000-000000000001'; const OTHER_TENANT = 'bbbbbbbb-0000-0000-0000-000000000002'; @@ -54,6 +55,12 @@ function buildApp( branding: { updateBranding: brandingStubs.updateBranding ?? vi.fn().mockResolvedValue(undefined), }, + // Real service over the test DB, not a stub. The PATCH handler + // swallows a failure here on purpose (the version row is evidence + // about a save, not part of it), so a stub that silently did + // nothing would let a broken wiring pass as a green test — which is + // exactly what happened the first time this spec was run. + legalVersion: new LegalVersionService(db as never), } as unknown as HonoConfig['Variables']['services']); await next(); }); @@ -211,3 +218,88 @@ describe('GET /api/admin/compliance/erasure-log (G4)', () => { expect(body.data).toEqual([]); }); }); + +/** + * Publishing a legal document records a version (design §6A.3). + * + * The read side is easy to build and easy to believe. The WRITE side is where + * this feature is silently wrong or silently noisy, and both failure modes look + * identical from the settings page: + * + * - recording nothing, because the PATCH handler never called the historian — + * the version table stays empty and "Last updated" never appears; + * - recording a version on EVERY PATCH, so a tenant who changes their booking + * hours mints a new revision of their privacy policy. + */ +describe('PATCH /api/admin/tenant-config — legal document versions', () => { + let db: BetterSQLite3Database; + + beforeEach(async () => { + const fixture = createTestDb(); + db = fixture.db; + await setupSchema(fixture.sqlite); + await db.insert(schema.tenants).values({ + id: TENANT_ID, name: 'Acme', slug: 'acme', status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + }); + await db.insert(schema.tenantConfigs).values({ + tenantId: TENANT_ID, defaultTimezone: 'UTC', updatedAt: new Date(), + }); + }); + + const patch = (app: OpenAPIHono, body: Record) => + request(app, '/api/admin/tenant-config', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + + const versions = () => db.select().from(schema.tenantLegalVersions).all(); + + it('records a version, WITH the body, when a legal document is saved', async () => { + const app = buildApp(db); + const res = await patch(app, { privacyBody: 'We collect only what an inspection needs.' }); + expect(res.status).toBe(200); + + const rows = await versions(); + expect(rows).toHaveLength(1); + expect(rows[0].doc).toBe('privacy'); + // The body, not just a hash — the source column is mutable with nothing + // behind it, so a row that cannot reproduce the text is worthless. + expect(rows[0].bodySnapshot).toBe('We collect only what an inspection needs.'); + expect(rows[0].contentHash).toMatch(/^[0-9a-f]{64}$/); + }); + + it('records NOTHING when the PATCH did not touch a legal document', async () => { + const app = buildApp(db); + const res = await patch(app, { agreementRetentionYears: 7 }); + expect(res.status).toBe(200); + expect(await versions()).toHaveLength(0); + }); + + it('records nothing on a re-save of identical text', async () => { + const app = buildApp(db); + await patch(app, { privacyBody: 'Same words.' }); + await patch(app, { privacyBody: 'Same words.' }); + expect(await versions()).toHaveLength(1); + }); + + it('versions the two documents independently', async () => { + const app = buildApp(db); + await patch(app, { privacyBody: 'P text.', termsBody: 'T text.' }); + const rows = await versions(); + expect(rows.map((r) => r.doc).sort()).toEqual(['privacy', 'terms']); + }); + + it('does not fail the settings save when recording the version throws', async () => { + // The version row is evidence ABOUT a save, not part of it. Failing the + // tenant's actual change because the historian fell over would lose the + // thing they asked for in order to protect the record of it. + const app = buildApp(db); + const boom = vi.spyOn(LegalVersionService.prototype, 'recordPublish') + .mockRejectedValue(new Error('registry down')); + const res = await patch(app, { privacyBody: 'Still saves.' }); + expect(res.status).toBe(200); + boom.mockRestore(); + }); +}); From 5f84d7f68300e2daf0e8f9f3f7d72470801bc79a Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 1 Aug 2026 01:20:13 +0800 Subject: [PATCH 27/48] fix(credentials): the badges now reach the recipient, not just the preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `inspectorSignature()` has rendered credential badges since Spec B and NO CALLER EVER SUPPLIED ANY. The feature was wired and dead in both directions: the renderer accepted a field nothing set, and `SenderSignature` — the type that carries a signature to the send path — had nowhere to put them. So every outbound email showed the legacy `license_number` line while Settings → Profile promised badges "shown on your reports, emails, and booking page". The preview was fixed in 44bfaa45. This is the other half: both resolvers (`resolveSignatureInspector` for an inspection's inspector, `lookupSenderSignature` for the acting user) now populate credentials, and every call site already passes the object through whole, so they reach the renderer unmodified. THE OPTIONAL FIELD IS WHY THIS WENT UNNOTICED, so the fix is structural rather than a promise to remember. `SignatureUser.credentials` has to stay optional — the renderer accepts callers that predate Spec B — but the resolvers now return `ResolvedSignature`, where it is required. The next omission is a compile error instead of a silently emptier email. THE ASSERTION THAT MATTERS IS THE ABSOLUTE URL. A spec checking only "credentials were passed" would pass while every recipient saw a broken image: the stored `imageUrl` is root-relative and a relative `src` inside an email resolves against the recipient's mail client, which is nowhere. The spec pins `src="https:///api/public/brand-asset…"` AND the absence of any `src="/api/public…"`, plus the text fallback — mail clients block remote images by default, so a credential that exists only as an `` is one most recipients never see. `CredentialService.listRenderable` replaces three hand-written copies of the same six-line mapping (booking's footer, the Profile preview, and this), which is how the badge URL form comes to differ between the email a client receives and the page they land on. Its spec covers the parts a re-implementation gets wrong: url-encoding a key containing a space, the inspector's own sort order, dropping inactive rows, and dropping a row that is neither badge nor label — a credential row is created BLANK and filled in, so an abandoned one would otherwise render as an empty chip. The report cover strip and report signature block are still fed nothing; that is the snapshot work, which is a larger piece. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HcJgdRoQzrRJ8RvEcpUDR5 --- server/api/profile.ts | 11 +--- server/lib/signature-helpers.ts | 57 +++++++++++++++-- server/services/booking.service.ts | 13 ++-- server/services/credential.service.ts | 43 +++++++++++++ tests/unit/credentials/service.spec.ts | 62 +++++++++++++++++++ .../email/email-signature-integration.spec.ts | 59 ++++++++++++++++++ 6 files changed, 224 insertions(+), 21 deletions(-) diff --git a/server/api/profile.ts b/server/api/profile.ts index 9bfe4be1b..17cb9103b 100644 --- a/server/api/profile.ts +++ b/server/api/profile.ts @@ -151,16 +151,7 @@ const profileRoutes = createApiRouter() // since Spec B; no caller ever supplied them, so the feature was wired // and dead — the badges render nowhere despite the settings copy // promising "shown on your reports, emails, and booking page". - const creds = await new CredentialService(c.env.DB).listByUser(tenantId, userId); - const credentials = creds - .filter((k) => k.active) - .map((k) => ({ - label: k.label, - memberNumber: k.memberNumber, - imageUrl: k.imageR2Key - ? `/api/public/brand-asset?key=${encodeURIComponent(k.imageR2Key)}` - : null, - })); + const credentials = await new CredentialService(c.env.DB).listRenderable(tenantId, userId); const signaturePreviewHtml = (row.name ?? '').trim() ? inspectorSignature({ diff --git a/server/lib/signature-helpers.ts b/server/lib/signature-helpers.ts index 38bb74804..da6991079 100644 --- a/server/lib/signature-helpers.ts +++ b/server/lib/signature-helpers.ts @@ -8,6 +8,7 @@ import { shouldUseCheckoutLink } from './agreement-link'; import { logger } from './logger'; import { getDrizzle } from './route-helpers'; import type { SignatureUser } from './inspector-signature'; +import { CredentialService, type RenderableCredential } from '../services/credential.service'; /** * Shared e-signature helpers used by the inspections, admin and invoices route @@ -16,7 +17,53 @@ import type { SignatureUser } from './inspector-signature'; * sign/checkout link. Behavior-preserving extraction — logic unchanged. */ -export type SenderSignature = { name: string | null; email: string | null; phone: string | null; licenseNumber: string | null; signatureEnabled: boolean | null }; +export type SenderSignature = { + name: string | null; + email: string | null; + phone: string | null; + licenseNumber: string | null; + signatureEnabled: boolean | null; + /** + * Spec B — the sender's active credentials. This field is why the badges + * reached nobody: `inspectorSignature()` has rendered credentials since Spec + * B, and the type that carries a signature to the send path had nowhere to + * put them, so every send silently omitted what the settings page promised + * was "shown on your reports, emails, and booking page". + */ + credentials: RenderableCredential[]; +}; + +/** + * A resolved signature, with credentials REQUIRED. + * + * `SignatureUser.credentials` is optional — it has to be, because the renderer + * accepts callers that predate Spec B. That optionality is exactly how the + * badges came to reach nobody: every resolver simply never set the field and + * nothing complained. Narrowing the resolvers' own return type turns the next + * omission into a compile error instead of a silently emptier email. + */ +export type ResolvedSignature = SignatureUser & { credentials: RenderableCredential[] }; + +/** + * The sender's renderable credentials, or none. + * + * Tolerant on purpose, and consistent with everything else in this module: a + * signature footer is a courtesy appended to an email that has a job to do. A + * failed credential lookup must cost the reader a badge, never the report link + * the email exists to deliver. + */ +async function safeCredentials( + c: Context, + tenantId: string, + userId: string, +): Promise { + try { + return await new CredentialService(c.env.DB).listRenderable(tenantId, userId); + } catch (err) { + logger.warn('email.signature.credentials.failed', { userId, error: (err as Error).message }); + return []; + } +} /** * Sprint B-4a — resolves the inspector record for an inspection so outbound @@ -29,7 +76,7 @@ export async function resolveSignatureInspector( c: Context, inspectorId: string | null | undefined, tenantId: string, -): Promise { +): Promise { if (!inspectorId) return undefined; try { const db = getDrizzle(c); @@ -45,7 +92,8 @@ export async function resolveSignatureInspector( // saas-aware: requestedTenantSlug is empty in saas, so the "Book again" // link would otherwise drop. Resolve via the shared helper (DB fallback). const tenantSlug = (await resolveTenantSlug(c, tenantId)) || null; - return { ...row, tenantSlug }; + const credentials = await safeCredentials(c, tenantId, inspectorId); + return { ...row, tenantSlug, credentials }; } catch (err) { logger.error('[email-signature] inspector lookup failed', { inspectorId }, err instanceof Error ? err : undefined); return undefined; @@ -70,7 +118,8 @@ export async function lookupSenderSignature(c: Context, tenantId: st }).from(users) .where(and(eq(users.id, senderId), eq(users.tenantId, tenantId))) .get(); - return row ?? undefined; + if (!row) return undefined; + return { ...row, credentials: await safeCredentials(c, tenantId, senderId) }; } catch (err) { logger.warn('agreement.signature.lookup.failed', { senderId, error: (err as Error).message }); return undefined; diff --git a/server/services/booking.service.ts b/server/services/booking.service.ts index 2bbd7e0ee..0b9d482e2 100644 --- a/server/services/booking.service.ts +++ b/server/services/booking.service.ts @@ -1,7 +1,8 @@ import type { Context } from 'hono'; import { drizzle } from 'drizzle-orm/d1'; -import { eq, and, gte, lte, sql, inArray, isNull, ne, asc } from 'drizzle-orm'; -import { availability, availabilityOverrides, calendarBlocks, inspections, inspectionInspectors, inspectionRequests, serviceInspectors, tenantConfigs, users, services as servicesTable, contactRoleProfiles, inspectorCredentials, contacts } from '../lib/db/schema'; +import { eq, and, gte, lte, sql, inArray, isNull, ne } from 'drizzle-orm'; +import { availability, availabilityOverrides, calendarBlocks, inspections, inspectionInspectors, inspectionRequests, serviceInspectors, tenantConfigs, users, services as servicesTable, contactRoleProfiles, contacts } from '../lib/db/schema'; +import { CredentialService } from './credential.service'; import { wallClockToEpochMs, resolveTenantTimeZone } from '../lib/tz'; import { Errors } from '../lib/errors'; import { safeISODate } from '../lib/date'; @@ -803,12 +804,10 @@ export class BookingService { const inspectorEmail = inspector?.email || c.env.SENDER_EMAIL || `noreply@${c.env.APP_NAME?.toLowerCase().replace(/\s/g, '') || 'inspector'}.com`; // Spec B — the assigned inspector's active credentials, for the footer. + // Via the shared mapper, so this footer and the email signature can + // never disagree about the badge URL form. const bookingCreds = inspector - ? (await db.select().from(inspectorCredentials) - .where(and(eq(inspectorCredentials.tenantId, tenantId), eq(inspectorCredentials.userId, inspectorId!), eq(inspectorCredentials.active, true))) - .orderBy(asc(inspectorCredentials.sortOrder)).all()) - .filter((cr) => cr.imageR2Key || (cr.label ?? '').trim()) - .map((cr) => ({ label: cr.label, memberNumber: cr.memberNumber, imageUrl: cr.imageR2Key ? `/api/public/brand-asset?key=${encodeURIComponent(cr.imageR2Key)}` : null })) + ? await new CredentialService(c.env.DB).listRenderable(tenantId, inspectorId!) : []; // Sprint B-4a — append inspector signature so customers can rebook // with the same inspector via the per-inspector booking link. diff --git a/server/services/credential.service.ts b/server/services/credential.service.ts index 8f3fad47b..34ada612b 100644 --- a/server/services/credential.service.ts +++ b/server/services/credential.service.ts @@ -7,6 +7,21 @@ import { Errors } from '../lib/errors'; export type InspectorCredential = InferSelectModel; +/** + * A credential as every RENDERER wants it (Spec B): the booking footer, the + * email signature, the report cover strip and the report signature block. + * + * `imageUrl` is the public brand-asset path, ROOT-RELATIVE. Callers that embed + * it in outbound HTML email must absolutise against the deployment host — + * `inspectorSignature()` does — because a relative path in an email resolves + * against the recipient's mail client, which is nowhere. + */ +export interface RenderableCredential { + label: string; + memberNumber: string | null; + imageUrl: string | null; +} + // Inspector Credentials & Association Badges (Spec B). Self-asserted per-inspector // credentials with an optional uploaded badge image (one R2 object per credential; // replace purges the old). Every query is fail-closed on (tenantId, userId). @@ -21,6 +36,34 @@ export class CredentialService { .orderBy(asc(inspectorCredentials.sortOrder), asc(inspectorCredentials.createdAt)).all(); } + /** + * The inspector's ACTIVE credentials, shaped for rendering. + * + * One function rather than a mapping copied per surface. There were three + * copies of these six lines when this was written — booking's footer, the + * Profile preview, and about to be the send path and the report payload — and + * three copies is exactly how the URL form comes to differ between the email + * a client receives and the page they land on. + * + * Filters to `active`, drops rows that are neither a badge nor a label + * (a credential row is created blank and filled in, so an abandoned one would + * otherwise render as an empty chip), and orders by the inspector's own + * `sortOrder`. + */ + async listRenderable(tenantId: string, userId: string): Promise { + const rows = await this.listByUser(tenantId, userId); + return rows + .filter((cr) => cr.active) + .filter((cr) => cr.imageR2Key || (cr.label ?? '').trim()) + .map((cr) => ({ + label: cr.label, + memberNumber: cr.memberNumber, + imageUrl: cr.imageR2Key + ? `/api/public/brand-asset?key=${encodeURIComponent(cr.imageR2Key)}` + : null, + })); + } + async create( tenantId: string, userId: string, diff --git a/tests/unit/credentials/service.spec.ts b/tests/unit/credentials/service.spec.ts index 82541a04e..c16c982b5 100644 --- a/tests/unit/credentials/service.spec.ts +++ b/tests/unit/credentials/service.spec.ts @@ -3,6 +3,7 @@ import { CredentialService } from '../../../server/services/credential.service'; import { createTestDb, setupSchema } from '../db'; import * as schema from '../../../server/lib/db/schema'; import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; @@ -54,3 +55,64 @@ describe('CredentialService', () => { expect((await svc.listByUser(T, U)).map((x) => x.label)).toEqual(['Mine']); // untouched }); }); + +/** + * `listRenderable` — the one mapping every surface uses. + * + * There were three hand-written copies of these six lines when this was + * extracted (booking's email footer, the Profile signature preview, and the + * report payload about to become a fourth). Three copies is how the badge URL + * comes to differ between the email a client receives and the page they land + * on, and the difference is invisible from either side. + */ +describe('CredentialService.listRenderable', () => { + let svc: CredentialService; + let testDb: BetterSQLite3Database; + + beforeEach(async () => { + const f = createTestDb(); testDb = f.db; await setupSchema(f.sqlite); + (mockDrizzle as unknown as ReturnType).mockReturnValue(testDb); + svc = new CredentialService({} as D1Database); + }); + + it('emits the public brand-asset path, url-encoded, in the inspector own order', async () => { + const a = await svc.create(T, U, { label: 'InterNACHI CPI', memberNumber: 'NACHI-1', sortOrder: 2 }); + await svc.create(T, U, { label: 'TX License', memberNumber: '22841', sortOrder: 1 }); + await testDb.update(schema.inspectorCredentials) + .set({ imageR2Key: 't1/credentials/logo one.png' }) + .where(eq(schema.inspectorCredentials.id, a.id)); + + const out = await svc.listRenderable(T, U); + expect(out.map((c) => c.label)).toEqual(['TX License', 'InterNACHI CPI']); + expect(out[0].imageUrl).toBeNull(); + // Encoded, because the key contains a space and a slash and this string is + // pasted straight into an href/src by four different renderers. + expect(out[1].imageUrl).toBe('/api/public/brand-asset?key=t1%2Fcredentials%2Flogo%20one.png'); + }); + + it('drops inactive credentials', async () => { + const a = await svc.create(T, U, { label: 'Retired cert' }); + await svc.create(T, U, { label: 'Live cert' }); + await testDb.update(schema.inspectorCredentials) + .set({ active: false }) + .where(eq(schema.inspectorCredentials.id, a.id)); + + expect((await svc.listRenderable(T, U)).map((c) => c.label)).toEqual(['Live cert']); + }); + + it('drops a row that is neither a badge nor a label', async () => { + // A credential row is created BLANK and filled in, so an abandoned one + // would otherwise render as an empty chip on the cover of a report. + await svc.create(T, U, { label: '' }); + await svc.create(T, U, { label: ' ' }); + await svc.create(T, U, { label: 'Real one' }); + expect((await svc.listRenderable(T, U)).map((c) => c.label)).toEqual(['Real one']); + }); + + it('never crosses a tenant or a user', async () => { + await svc.create(T, U, { label: 'Mine' }); + await svc.create(T2, U, { label: 'Other tenant' }); + await svc.create(T, U2, { label: 'Other user' }); + expect((await svc.listRenderable(T, U)).map((c) => c.label)).toEqual(['Mine']); + }); +}); diff --git a/tests/unit/email/email-signature-integration.spec.ts b/tests/unit/email/email-signature-integration.spec.ts index 471a57965..2ed4d61e0 100644 --- a/tests/unit/email/email-signature-integration.spec.ts +++ b/tests/unit/email/email-signature-integration.spec.ts @@ -108,3 +108,62 @@ describe('EmailService — signature footer (Sprint B-4a + B-4c)', () => { expect(sent[0]?.html).not.toContain('/book/'); }); }); + +/** + * Credentials on the SEND path, not just in the preview. + * + * `inspectorSignature()` has rendered credential badges since Spec B and no + * caller ever supplied any, so the feature was wired and dead: every recipient + * got the legacy license line while Settings → Profile promised badges "shown on + * your reports, emails, and booking page". The resolvers now populate them. + * + * THE ASSERTION THAT MATTERS IS THE ABSOLUTE URL. A spec that only checked + * "credentials were passed" would pass while every recipient saw a broken + * image: the stored `imageUrl` is root-relative (`/api/public/brand-asset?…`), + * and a relative src inside an email resolves against the recipient's mail + * client, which is nowhere. + */ +describe('EmailService — credential badges reach the recipient', () => { + let svc: EmailService; + let sent: SentCall[]; + + const WITH_CREDENTIALS = { + ...STUB_INSPECTOR, + credentials: [ + { label: 'InterNACHI Certified', memberNumber: 'NACHI-22', imageUrl: '/api/public/brand-asset?key=t1%2Fcred%2Flogo.png' }, + { label: 'Licensed home inspector', memberNumber: 'TX-9001', imageUrl: null }, + ], + }; + + beforeEach(() => { + const fixture = makeService(); + svc = fixture.svc; + sent = fixture.sent; + }); + + it('renders the badge image as an ABSOLUTE url against the deployment host', async () => { + await svc.sendReportReady('client@example.com', '1 Main St', 'https://r.example/abc', WITH_CREDENTIALS, HOST); + const html = sent[0]?.html ?? ''; + expect(html).toMatch(/]+src="https:\/\/app\.inspectorhub\.io\/api\/public\/brand-asset/); + // The negative half: no `src="/…"` anywhere in the signature, which is + // what shipping the stored value verbatim would produce. + expect(html).not.toMatch(/]+src="\/api\/public/); + }); + + it('renders a text-only credential too, so a blocked image never loses it', async () => { + await svc.sendReportReady('client@example.com', '1 Main St', 'https://r.example/abc', WITH_CREDENTIALS, HOST); + const html = sent[0]?.html ?? ''; + // Mail clients block remote images by default. A credential that exists + // only as an is a credential most recipients never see. + expect(html).toContain('InterNACHI Certified #NACHI-22'); + expect(html).toContain('Licensed home inspector #TX-9001'); + }); + + it('still sends a clean signature for an inspector with no credentials', async () => { + await svc.sendReportReady('client@example.com', '1 Main St', 'https://r.example/abc', + { ...STUB_INSPECTOR, credentials: [] }, HOST); + const html = sent[0]?.html ?? ''; + expect(html).toContain('Mike Reynolds'); + expect(html).not.toContain(' Date: Sat, 1 Aug 2026 01:56:53 +0800 Subject: [PATCH 28/48] refactor(settings): a button means submit; its absence means it saved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Profile had six sections and ONE save affordance, floating over all of them and owning one. The other five saved on upload, on toggle, on sign, on blur, on click. So the sticky "Save Profile" bar taught the wrong rule in both directions: a reader who edited a credential saw it and assumed nothing had been saved yet (it had), and a reader who edited their name watched it follow them down the page with no sign of what it belonged to. The button now lives INSIDE the profile card, and the form contains only that card. Every other section saves itself — which was already true of four of them, and is now true of the email-signature toggle as well. It was a checkbox inside the profile form, saved by the page's button along with name and phone: a control that looked self-contained and was not. MOVING IT OUT SET A TRAP, which is the part worth reading. The old code did `fd.getAll("signatureEnabled").at(-1) === "true"`. On a form that no longer carries the field that evaluates `undefined === "true"` and writes FALSE — so saving an unrelated profile field would quietly switch an inspector's email signature off, with nothing on screen to say so; they would find out from a recipient. `signatureEnabledFromForm` makes absence distinct from false, and its spec turns red the moment the guard is removed. The saves with no button now have to be confirmable, or "no button means it saved" is a claim the page cannot back: - credentials save on BLUR, the most invisible save here — nothing moved when it worked and nothing moved when it did not; - a FAILED photo upload said nothing at all (success reloads the page, and failure left the old photo sitting there looking untouched); - and all four credential handlers `await`ed the call and returned `success: true` whatever came back, so a rejected write reported as a save. Survivable while nothing rendered the result; not survivable now. The two signature cards moved into their own module, because holding this rule in the route meant the route also held two fetchers, a toast, the pad's state and their markup on top of the one form it actually submits. Each card owning its own is what makes the rule legible rather than asserted. Their specs pin that neither grew a submit control. Verified in the browser before the extraction: one submit button, inside `#profile-details`; no sticky bar; the form contains only that section; the toggle persists (`is_signature_enabled` 1 -> 0) and reports "Saved"; and a profile save afterwards leaves the flag alone. Both themes checked by computed style — card, divider and button tokens all resolve per theme. Chrome's screenshot transport failed partway through (a CDP parameter error), so the post-extraction pass is render specs and type-check rather than a picture. File-size baseline: +15 after extracting 90 lines out. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HcJgdRoQzrRJ8RvEcpUDR5 --- .../settings/SignatureCards.test.tsx | 83 ++++++ app/components/settings/SignatureCards.tsx | 128 ++++++++ app/routes/settings-profile.test.tsx | 38 +++ app/routes/settings-profile.tsx | 277 +++++++++--------- scripts/file-size-baseline.json | 4 +- 5 files changed, 397 insertions(+), 133 deletions(-) create mode 100644 app/components/settings/SignatureCards.test.tsx create mode 100644 app/components/settings/SignatureCards.tsx create mode 100644 app/routes/settings-profile.test.tsx diff --git a/app/components/settings/SignatureCards.test.tsx b/app/components/settings/SignatureCards.test.tsx new file mode 100644 index 000000000..1498a8776 --- /dev/null +++ b/app/components/settings/SignatureCards.test.tsx @@ -0,0 +1,83 @@ +/** + * The two signature cards, and the rule they exist to keep. + * + * Settings → Profile used to carry ONE Save button, floating over six sections + * and owning one of them. The other five saved on upload, on toggle, on sign, + * on blur, on click — so the button taught the wrong rule in both directions. + * Now the button owns the card it sits in, and every other card saves itself. + * + * That rule has a hard edge: a card with no button is claiming "this is already + * saved". These specs pin that neither card grew a submit control, and that the + * toggle is the thing that saves. + */ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { createRoutesStub } from "react-router"; +import { EmailSignatureCard, SavedSignatureCard } from "./SignatureCards"; + +/** + * Both cards submit through `useFetcher`, which needs a router. The stub is + * used for RENDERING only — never for an auth assertion, which it cannot make + * (it does not run middleware). + */ +function renderInRouter(ui: React.ReactElement, action = vi.fn(() => ({ success: true }))) { + const Stub = createRoutesStub([{ path: "/", Component: () => ui, action }]); + return { ...render(), action }; +} + +describe("EmailSignatureCard", () => { + it("renders the toggle and the preview", () => { + renderInRouter( + , + ); + expect(screen.getByRole("checkbox")).toBeChecked(); + expect(screen.getByText("— Dana Inspector")).toBeTruthy(); + }); + + it("has NO submit control — the toggle is the save", () => { + const { container } = renderInRouter(); + // The whole point of the restructure. A button here would put the page back + // to two competing save affordances for one card. + expect(container.querySelectorAll("button[type=submit]")).toHaveLength(0); + expect(container.querySelectorAll("button")).toHaveLength(0); + }); + + it("submits the toggle's new value, not its old one", async () => { + const { action } = renderInRouter(); + fireEvent.click(screen.getByRole("checkbox")); + await vi.waitFor(() => expect(action).toHaveBeenCalled()); + const req: Request = action.mock.calls[0][0].request; + const fd = await req.formData(); + expect(fd.get("intent")).toBe("signature-toggle"); + // Reading `.checked` AFTER the click, not inverting the prop — the two + // differ the moment anything else touches the box. + expect(fd.get("signatureEnabled")).toBe("false"); + }); + + it("says WHY the signature is empty rather than showing a blank frame", () => { + const { container } = renderInRouter(); + // The copy points "above", which after the restructure is the profile card + // holding exactly those fields — so the instruction still resolves. + expect(screen.getByText(/Add your name .* above to build a signature/)).toBeTruthy(); + expect(container.querySelector(".bg-ih-bg-muted")).toBeNull(); + }); +}); + +describe("SavedSignatureCard", () => { + it("offers to add a signature, and nothing that looks like a form submit", () => { + const { container } = renderInRouter(); + const buttons = container.querySelectorAll("button"); + expect(buttons).toHaveLength(1); + // `type="button"` — it opens the pad. Signing is what saves. + expect(buttons[0].getAttribute("type")).toBe("button"); + expect(container.querySelectorAll("button[type=submit]")).toHaveLength(0); + }); + + it("opens the signature pad on click", () => { + renderInRouter(); + fireEvent.click(screen.getByRole("button")); + // The pad replaces the button: there is no state where both are offered, + // which is what would let someone sign and then hit "add" expecting a save. + expect(screen.queryByRole("button", { name: /add|update/i })).toBeNull(); + }); +}); diff --git a/app/components/settings/SignatureCards.tsx b/app/components/settings/SignatureCards.tsx new file mode 100644 index 000000000..92083a755 --- /dev/null +++ b/app/components/settings/SignatureCards.tsx @@ -0,0 +1,128 @@ +import { useState } from "react"; +import { useFetcher } from "react-router"; +import { SignaturePad } from "~/components/SignaturePad"; +import { useNotificationSaveToast } from "~/hooks/useNotificationSaveToast"; +import { m } from "~/paraglide/messages"; + +/** + * The two signature cards on Settings → Profile, lifted out of the route. + * + * They are here because each OWNS ITS OWN SAVE, which is the rule the Profile + * page now keeps: a button means you must submit, and its absence means it is + * already done. Holding that rule in the route meant the route also held two + * fetchers, a toast, a pad's open/closed state and their markup, on top of the + * one form it actually submits. Each card carrying its own is what makes the + * rule legible instead of asserted. + */ + +type SaveResult = { success?: boolean; error?: string; intent?: string }; + +/** + * The email-signature footer: an opt-in toggle and a live preview. + * + * The toggle used to be a checkbox inside the profile form, saved by the page's + * Save button along with name and phone — a control that looked self-contained + * and was not. It saves itself now. + */ +export function EmailSignatureCard({ + enabled, previewHtml, +}: { enabled: boolean; previewHtml: string | null }) { + const fetcher = useFetcher(); + const failed = fetcher.data?.intent === "signature-toggle" && fetcher.data?.success === false; + useNotificationSaveToast({ + data: fetcher.data?.intent === "signature-toggle" ? fetcher.data : null, + failed, + error: fetcher.data?.error ?? null, + }); + + return ( +
+
+

{m.settings_profile_signature_heading()}

+

{m.settings_profile_signature_subtitle()}

+
+ + {/* Saves on change. There is no button here because there is nothing left + to submit, and the toast is what makes that claim checkable. */} + + + {previewHtml ? ( +
+
{m.settings_profile_signature_preview_label()}
+
+
+ ) : ( +

{m.settings_profile_signature_empty()}

+ )} +
+ ); +} + +/** + * The drawn signature used on reports and agreements. + * + * Its feedback stays INLINE rather than becoming a toast: the pad is a modal + * act the reader is looking straight at when it resolves, so the confirmation + * belongs where their attention already is. (The toast exists for the saves + * that happen without ceremony — a blurred field, a flipped checkbox.) + */ +export function SavedSignatureCard() { + const fetcher = useFetcher(); + const [showPad, setShowPad] = useState(false); + const isOurs = fetcher.data?.intent === "save-signature"; + const saved = isOurs && fetcher.data?.success === true; + const error = isOurs && typeof fetcher.data?.error === "string" ? fetcher.data.error : null; + + return ( +
+
+

{m.settings_profile_saved_signature_heading()}

+

{m.settings_profile_saved_signature_subtitle()}

+
+ + {saved && ( +
+ {m.settings_profile_signature_saved_flash()} +
+ )} + {error && ( +
+ {error} +
+ )} + + {showPad ? ( + setShowPad(false)} + onSubmit={(dataUri) => { + const fd = new FormData(); + fd.append("intent", "save-signature"); + fd.append("signatureBase64", dataUri); + fetcher.submit(fd, { method: "post" }); + setShowPad(false); + }} + /> + ) : ( + + )} +
+ ); +} diff --git a/app/routes/settings-profile.test.tsx b/app/routes/settings-profile.test.tsx new file mode 100644 index 000000000..34d7a3643 --- /dev/null +++ b/app/routes/settings-profile.test.tsx @@ -0,0 +1,38 @@ +/** + * The Profile page's Save button now owns exactly one card, and the email + * signature toggle saves itself. That move created a trap on the way out. + */ +import { describe, it, expect } from "vitest"; +import { signatureEnabledFromForm } from "./settings-profile"; + +function form(...pairs: Array<[string, string]>): FormData { + const fd = new FormData(); + for (const [k, v] of pairs) fd.append(k, v); + return fd; +} + +describe("signatureEnabledFromForm", () => { + it("returns undefined when the form does not carry the field at all", () => { + // THE ONE THAT MATTERS. The profile form no longer submits this field, so + // reading it as a boolean would evaluate `undefined === "true"` and write + // `false` — switching an inspector's email signature off every time they + // saved an unrelated field, with nothing on screen to say so. + expect(signatureEnabledFromForm(form(["name", "Dana"]))).toBeUndefined(); + expect(signatureEnabledFromForm(form())).toBeUndefined(); + }); + + it("takes the LAST value, because a checked box arrives after its hidden false", () => { + expect(signatureEnabledFromForm(form(["signatureEnabled", "false"], ["signatureEnabled", "true"]))).toBe(true); + expect(signatureEnabledFromForm(form(["signatureEnabled", "false"]))).toBe(false); + }); + + it("reads a single explicit value", () => { + expect(signatureEnabledFromForm(form(["signatureEnabled", "true"]))).toBe(true); + }); + + it("treats anything that is not the string \"true\" as off", () => { + for (const v of ["", "1", "on", "TRUE", "yes"]) { + expect(signatureEnabledFromForm(form(["signatureEnabled", v])), v).toBe(false); + } + }); +}); diff --git a/app/routes/settings-profile.tsx b/app/routes/settings-profile.tsx index 2f4aaa6ee..c714a0c1e 100644 --- a/app/routes/settings-profile.tsx +++ b/app/routes/settings-profile.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from "react"; -import { Form, useLoaderData, useActionData, useFetcher } from "react-router"; +import { Form, useLoaderData, useActionData, useFetcher, useNavigation } from "react-router"; import { SettingsCrumb } from "~/components/SettingsCrumb"; import { BrowserTimezoneHint } from "~/components/settings/BrowserTimezoneHint"; import { useSessionContext } from "~/hooks/useSessionContext"; @@ -8,9 +8,7 @@ import { parseWithZod } from "@conform-to/zod/v4"; import type { Route } from "./+types/settings-profile"; import { requireToken } from "~/lib/session.server"; import { createApi } from "~/lib/api-client.server"; -import { SignaturePad } from "~/components/SignaturePad"; import { AvatarCropper } from "~/components/media-studio/AvatarCropper"; -import { SettingsSaveBar } from "~/components/settings/SettingsSaveBar"; import { makeProfileSchema } from "~/lib/forms/settings.schema"; import { Select } from "@core/shared-ui"; import { TIMEZONE_SELECT_OPTIONS } from "~/lib/timezones"; @@ -18,6 +16,8 @@ import { LOCALE_OPTIONS } from "~/lib/locales"; import { SectionNav } from "~/components/settings/SectionNav"; import { CredentialsEditor, type EditorCredential } from "~/components/settings/CredentialsEditor"; import { NotificationPreferencesCard } from "~/components/settings/NotificationPreferencesCard"; +import { EmailSignatureCard, SavedSignatureCard } from "~/components/settings/SignatureCards"; +import { useNotificationSaveToast } from "~/hooks/useNotificationSaveToast"; import { bulkNotificationChoice, grantNotificationSms, loadNotificationScreen, saveNotificationChoice } from "~/lib/settings-notifications.server"; import { m } from "~/paraglide/messages"; @@ -59,6 +59,27 @@ export async function loader({ request, context }: Route.LoaderArgs) { /* Action */ /* ------------------------------------------------------------------ */ +/** + * The email-signature flag carried by a form, or `undefined` when the form does + * not carry one. + * + * ABSENCE IS NOT `false`, and that distinction is the whole function. The + * toggle used to live inside the profile form; it now saves itself, so the + * profile form no longer submits it. The obvious read — + * `fd.getAll(...).at(-1) === "true"` — evaluates `undefined === "true"` on a + * form that omits the field, quietly switching every inspector's signature OFF + * the next time they save an unrelated profile field. Nothing on screen would + * say so; they would find out from a recipient. + * + * When the field IS present it arrives twice (a hidden `false` plus a checked + * `true`), so the last value wins. + */ +export function signatureEnabledFromForm(fd: FormData): boolean | undefined { + if (!fd.has("signatureEnabled")) return undefined; + const vals = fd.getAll("signatureEnabled"); + return vals[vals.length - 1] === "true"; +} + export async function action({ request, context }: Route.ActionArgs) { const token = await requireToken(context, request); const api = createApi(context, { token }); @@ -113,22 +134,32 @@ export async function action({ request, context }: Route.ActionArgs) { // Inspector credentials (Spec B) — each mutation revalidates the loader so the // editor re-renders with fresh rows. '' member number clears to null. + // Each of these four used to `await` the call and return `success: true` + // whatever came back, so a rejected write reported as a save. That was + // survivable while nothing rendered the result; it is not survivable now that + // the page's rule is "no button means it saved" and these are the sections + // with no button. + // Typed structurally rather than as `Response`: hono/client returns a + // `ClientResponse`, which carries the response contract but not Workers' + // `webSocket` field. Only `ok` and `json()` are read here. + const credentialResult = async (res: { ok: boolean; json: () => Promise }, i: string) => { + if (res.ok) return { success: true, error: null, intent: i }; + const err = await res.json().catch(() => ({})); + return { success: false, error: (err as Record)?.message || m.settings_error_save_failed(), intent: i }; + }; if (intent === "credential-add") { - await api.credentials.index.$post({ json: { label: "" } }); - return { success: true, error: null, intent }; + return credentialResult(await api.credentials.index.$post({ json: { label: "" } }), intent); } if (intent === "credential-update") { const id = fd.get("id") as string; const patch: Record = {}; if (fd.has("label")) patch.label = fd.get("label") as string; if (fd.has("memberNumber")) patch.memberNumber = (fd.get("memberNumber") as string) || null; - await api.credentials[":id"].$patch({ param: { id }, json: patch }); - return { success: true, error: null, intent }; + return credentialResult(await api.credentials[":id"].$patch({ param: { id }, json: patch }), intent); } if (intent === "credential-delete") { const id = fd.get("id") as string; - await api.credentials[":id"].$delete({ param: { id } }); - return { success: true, error: null, intent }; + return credentialResult(await api.credentials[":id"].$delete({ param: { id } }), intent); } if (intent === "credential-image") { const id = fd.get("id") as string; @@ -136,7 +167,22 @@ export async function action({ request, context }: Route.ActionArgs) { if (!(image instanceof File) || image.size === 0) { return { success: false, error: m.settings_profile_error_no_photo(), intent }; } - await api.credentials[":id"].image.$post({ param: { id }, form: { image } } as Parameters[0]); + return credentialResult( + await api.credentials[":id"].image.$post({ param: { id }, form: { image } } as Parameters[0]), + intent, + ); + } + + // The email-signature toggle saves itself (it is no longer inside the profile + // form), so it needs its own intent rather than riding the default branch. + if (intent === "signature-toggle") { + const res = await api.profile.index.$patch({ + json: { signatureEnabled: fd.get("signatureEnabled") === "true" }, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + return { success: false, error: (err as Record)?.message || m.settings_error_save_failed(), intent }; + } return { success: true, error: null, intent }; } @@ -156,9 +202,8 @@ export async function action({ request, context }: Route.ActionArgs) { if (v.timezone !== undefined) body.timezone = v.timezone; // Per-user locale override. Same contract as timezone: '' clears (inherit tenant). if (v.locale !== undefined) body.locale = v.locale; - // Email signature toggle: hidden "false" + optional checkbox "true" — last value wins. - const sigVals = fd.getAll("signatureEnabled"); - body.signatureEnabled = sigVals[sigVals.length - 1] === "true"; + const sigEnabled = signatureEnabledFromForm(fd); + if (sigEnabled !== undefined) body.signatureEnabled = sigEnabled; const res = await api.profile.index.$patch({ json: body }); if (!res.ok) { const err = await res.json().catch(() => ({})); @@ -191,6 +236,10 @@ export default function SettingsProfilePage() { shouldRevalidate: "onInput", }); + // The profile form is the only thing left on this page that SUBMITS, so it is + // the only thing that reads route navigation state. + const savingProfile = useNavigation().state === "submitting"; + // Conform narrowing helpers (cat-7): actionData may be SubmissionResult or {success,error,...} const flashSuccess = actionData && "success" in actionData && actionData.success; const flashError = actionData && "error" in actionData && typeof actionData.error === "string" ? actionData.error : null; @@ -202,11 +251,34 @@ export default function SettingsProfilePage() { window.location.reload(); } }, [photoFetcher.state, photoFetcher.data]); + // A FAILED upload used to say nothing at all: success reloads the page, and + // failure left the old photo sitting there looking like nothing had been + // attempted. On a page whose rule is now "no button means it saved", a + // silent failure is the one thing that breaks the rule. + useNotificationSaveToast({ + data: photoFetcher.data?.intent === "photo-upload" && !photoFetcher.data?.success + ? photoFetcher.data : null, + failed: true, + error: photoFetcher.data?.error ?? null, + }); // Inspector credentials (Spec B) — mutations route through the action (BFF); // RR revalidates the loader afterward, so the editor re-renders with fresh rows. - const credFetcher = useFetcher(); - const credImageFetcher = useFetcher<{ intent?: string }>(); + const credFetcher = useFetcher<{ success?: boolean; error?: string; intent?: string }>(); + const credImageFetcher = useFetcher<{ success?: boolean; error?: string; intent?: string }>(); + // Credentials save on BLUR — the most invisible save on the page, because + // nothing moves when it works and nothing moves when it does not. Both + // fetchers report, so leaving a field is a confirmable act. + useNotificationSaveToast({ + data: credFetcher.data ?? null, + failed: credFetcher.data?.success === false, + error: credFetcher.data?.error ?? null, + }); + useNotificationSaveToast({ + data: credImageFetcher.data ?? null, + failed: credImageFetcher.data?.success === false, + error: credImageFetcher.data?.error ?? null, + }); const [uploadingCredId, setUploadingCredId] = useState(null); useEffect(() => { if (credImageFetcher.state === "idle") setUploadingCredId(null); @@ -252,16 +324,6 @@ export default function SettingsProfilePage() { setSelectedTz(zone); } - // Signature pad state - const sigFetcher = useFetcher(); - const [showSigPad, setShowSigPad] = useState(false); - const sigSaved = sigFetcher.data && "success" in sigFetcher.data && sigFetcher.data.success - && "intent" in sigFetcher.data && sigFetcher.data.intent === "save-signature"; - const sigError = sigFetcher.data && "error" in sigFetcher.data - && typeof sigFetcher.data.error === "string" && sigFetcher.data.error - && "intent" in sigFetcher.data && sigFetcher.data.intent === "save-signature" - ? (sigFetcher.data.error as string) : null; - const navSections = [ { id: "profile-details", label: m.settings_profile_crumb() }, { id: "photo", label: m.settings_profile_photo_heading() }, @@ -373,125 +435,78 @@ export default function SettingsProfilePage() { ]} />
-
- {/* DB-12 / IA-26 — Booking slug section removed; the company booking link - now lives in Settings → Booking ("Your links"). */} - - {/* Photo placeholder */} -
-
-

{m.settings_profile_photo_heading()}

-

{m.settings_profile_photo_subtitle()}

-
- - {/* Photo */} -
- -
-
- {profile.photoUrl ? ( - {m.settings_profile_photo_alt()} - ) : ( - {m.settings_profile_photo_none()} - )} -
-
- { - const file = e.target.files?.[0]; - if (file) setAvatarSource(URL.createObjectURL(file)); - e.target.value = ""; - }} - /> -

{m.settings_profile_photo_hint()}

-
+ {form.errors && ( +
+ {form.errors[0]}
-
- -
- - {/* Email signature (business-card footer) — independent of Point of Contact */} -
-
-

{m.settings_profile_signature_heading()}

-

- {m.settings_profile_signature_subtitle()} -

-
- - - - - {profile.signaturePreviewHtml ? ( -
-
{m.settings_profile_signature_preview_label()}
-
-
- ) : ( -

{m.settings_profile_signature_empty()}

)} -
- {form.errors && ( -
- {form.errors[0]} + {/* Save lives INSIDE the card it owns, and nowhere else on the page. + It used to be a sticky bar spanning all six sections while owning + one, which taught the wrong rule in both directions: a reader who + edited a credential saw it and assumed nothing was saved yet (it + was), and a reader who edited these fields watched it follow them + down the page with no sign of what it belonged to. */} +
+
- )} - - {/* Save — sticky bar pinned to the bottom of the settings scroll area */} - + - {/* Saved signature */} -
+ {/* DB-12 / IA-26 — Booking slug section removed; the company booking link + now lives in Settings → Booking ("Your links"). */} + + {/* Photo placeholder */} +
-

{m.settings_profile_saved_signature_heading()}

-

- {m.settings_profile_saved_signature_subtitle()} -

+

{m.settings_profile_photo_heading()}

+

{m.settings_profile_photo_subtitle()}

- {sigSaved && ( -
- {m.settings_profile_signature_saved_flash()} -
- )} - {sigError && ( -
- {sigError} + {/* Photo */} +
+ +
+
+ {profile.photoUrl ? ( + {m.settings_profile_photo_alt()} + ) : ( + {m.settings_profile_photo_none()} + )} +
+
+ { + const file = e.target.files?.[0]; + if (file) setAvatarSource(URL.createObjectURL(file)); + e.target.value = ""; + }} + /> +

{m.settings_profile_photo_hint()}

+
- )} - - {showSigPad ? ( - setShowSigPad(false)} - onSubmit={async (dataUri) => { - const fd = new FormData(); - fd.append("intent", "save-signature"); - fd.append("signatureBase64", dataUri); - sigFetcher.submit(fd, { method: "post" }); - setShowSigPad(false); - }} - /> - ) : ( - - )} +
+
+ {/* Email signature (business-card footer) — independent of Point of Contact */} + + + + Date: Sat, 1 Aug 2026 03:01:44 +0800 Subject: [PATCH 29/48] feat(reports): a published version renders what it froze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec B §1 asks report surfaces to snapshot credentials and the resolved layout at publish while live surfaces read current state. The snapshot captured `{ inspection, data, units }` and nothing else, and the report resolved credentials LIVE on every read — so an inspector who left an association silently rewrote the cover of every report they had ever delivered, including ones a client downloaded months earlier and may be relying on. TWO FINDINGS THAT CHANGED THE SCOPE, both the opposite of what the plan assumed. First, the plan's audit said the report cover and signature block were "fed nothing" (`grep inspectorCredentials server/` → zero hits). That is not true and has not been since #260: the payload carries them, the response schema declares them, the loader reads them. What was actually missing was not the wiring — it was that the wiring resolved LIVE. Second, and this is the one worth keeping: growing the snapshot was assumed to need a dual-basis verifier, since every existing version was hashed without these fields. It does not. `content_hash` is the SHA-256 of the stored `snapshot_json` STRING and `verifyByToken` recomputes it from that same stored column, so a row written under the old shape keeps hashing to exactly what it hashed to. No versioned hashing basis, no migration of signed rows. A spec now pins that directly by re-signing a row in the old shape and verifying it — because if someone later "simplifies" the verifier into re-serialising a parsed object, every report issued before today starts reporting as TAMPERED on its own verification page, and nothing else would catch it. `schemaVersion` still earns its place, for readers rather than hashes: a v1 row has no `inspectors` and a v2 row may have an empty one. Identical as JSON, opposite as a claim on a cover page — "this predates the capture, live is all there is" versus "this inspector held none". `pinnedLeadCredentials` returns null for the first and `[]` for the second, and its spec pins that they are not the same answer, because `?? live` silently does the wrong thing on one of them. `inspectors` is a LIST from day one though only the lead's badges render (option A, matching the report's single name and single signer). Whether to credit helpers is a product call; what this fixes is that making it later must not mean migrating every stored snapshot. The role travels with the credentials because a badge is a claim about a PERSON on a document about an INSPECTION — an unattributed pool would state something neither of them said. THE PER-VERSION PDF WAS NEVER FROZEN. `verify.ts` built a render URL naming no version, so the "immutable" artifact was generated from the LIVE page the first time anyone downloaded it — which may be long after publication. The freezing was an R2 cache key, not a property of the document. The version now travels into the render, INSIDE the signed token rather than as a query param: a link holder who could append `&v=1` would be asking the renderer for a version they were never sent. A spec re-encodes the token body with a different version and asserts it fails to verify. Scope, stated plainly: pinned reads take credentials and the resolved style preset from the snapshot. Sections, ratings and photos are still derived live from the template, so a template edited after publication still moves them. That is the remainder of option A and it is a larger piece — this commit is the part that closes the credential requirement, not the whole read path. Three grandfathered files bumped (+29/+5/+2); the snapshot loader was extracted to `lib/report-snapshot.ts` rather than left in a 920-line assembly service, which took the largest of those from +60 down to +29. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HcJgdRoQzrRJ8RvEcpUDR5 --- scripts/file-size-baseline.json | 6 +- server/api/public-report.ts | 11 +- server/api/public/verify.ts | 7 +- server/lib/public-urls.ts | 6 +- server/lib/render-token.ts | 21 +- server/lib/report-snapshot.ts | 44 ++++ server/lib/version-diff.ts | 78 ++++++ server/services/inspection.service.ts | 4 +- .../inspection/inspection-report.service.ts | 57 +++-- server/services/report-version.service.ts | 103 +++++++- .../reports/public-report-endpoint.spec.ts | 14 +- tests/unit/reports/report-access-gate.spec.ts | 14 +- .../report-pinned-version-read.spec.ts | 132 ++++++++++ .../reports/report-version-snapshot.spec.ts | 237 ++++++++++++++++++ 14 files changed, 701 insertions(+), 33 deletions(-) create mode 100644 server/lib/report-snapshot.ts create mode 100644 tests/unit/reports/report-pinned-version-read.spec.ts create mode 100644 tests/unit/reports/report-version-snapshot.spec.ts diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 8e656f4bf..0528bd6d0 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -3,14 +3,14 @@ "app/routes/inspector-portal.tsx": 1180, "server/services/inspection/inspection-core.service.ts": 1117, "server/services/booking.service.ts": 967, - "server/services/inspection/inspection-report.service.ts": 920, + "server/services/inspection/inspection-report.service.ts": 949, "server/durable-objects/inspection-doc.ts": 912, "server/lib/collab/results-doc.ts": 874, "app/routes/inspections.tsx": 867, "server/api/sms.ts": 843, "app/components/portal/sections/ReportView.tsx": 806, "app/routes/settings-communication.tsx": 777, - "server/services/inspection.service.ts": 755, + "server/services/inspection.service.ts": 757, "server/api/admin/admin-settings.ts": 742, "server/api/inspections/report-delivery.ts": 736, "app/routes/settings-communication-templates.tsx": 731, @@ -33,7 +33,7 @@ "app/lib/collab/results-binding.ts": 560, "server/api/calendar.ts": 547, "app/routes/settings-profile.tsx": 545, - "server/api/public-report.ts": 536, + "server/api/public-report.ts": 541, "server/services/inspection/inspection-photo.service.ts": 531, "app/components/NewInspectionWizard.tsx": 530, "server/api/inspections/media-studio.ts": 530, diff --git a/server/api/public-report.ts b/server/api/public-report.ts index c2fb62472..54fd3c386 100644 --- a/server/api/public-report.ts +++ b/server/api/public-report.ts @@ -34,7 +34,7 @@ import { getDrizzle } from '../lib/route-helpers'; */ export async function resolveRenderAccess( render: string | undefined, requestedId: string, secret: string, -): Promise<{ inspectionId: string } | null> { +): Promise<{ inspectionId: string; versionNumber?: number } | null> { if (!render) return null; const v = await verifyRenderToken(render, secret); if (!v || v.inspectionId !== requestedId) return null; @@ -269,13 +269,18 @@ const publicReportRoutes = createApiRouter() // and pass it as `?render=`. Resolve tenantId from the inspection row so the // headless browser can load the full report without any user credential. let renderMode = false; + // A version named INSIDE the signed render token, when the caller is + // materialising a specific published version (the verify page's frozen + // PDF). Never read from the query string: a link holder who could append + // `&v=1` would be asking the renderer for a version they were never sent. + let pinnedVersion: number | undefined; if (!tenantId && render) { const r = await resolveRenderAccess(render, id, c.env.JWT_SECRET); if (r) { const db = getDrizzle(c); const row = await db.select({ tenantId: inspections.tenantId }) .from(inspections).where(eq(inspections.id, id)).get(); - if (row) { tenantId = row.tenantId; renderMode = true; } + if (row) { tenantId = row.tenantId; renderMode = true; pinnedVersion = r.versionNumber; } } } // Owner-session preview: an authenticated tenant user (inspector/admin) @@ -337,7 +342,7 @@ const publicReportRoutes = createApiRouter() streamCustomerSubdomain, appBaseUrl, r2BaseUrl: `/api/inspections/${id}/media/video`, - }); + }, pinnedVersion); return c.json({ success: true as const, data }, 200); }) .openapi(reportPhotoRoute, async (c) => { diff --git a/server/api/public/verify.ts b/server/api/public/verify.ts index fbff8410b..8c10d24d4 100644 --- a/server/api/public/verify.ts +++ b/server/api/public/verify.ts @@ -178,7 +178,12 @@ const publicVerifyRoutes = createApiRouter() // getOrRender will return cached row on content-hash hit; on miss it // renders once and stores with versionNumber so subsequent hits are instant. const tenantSlug = await resolveTenantSlug(c, tenantId); - const reportUrl = await buildRenderReportUrl(getBookingHost(c), tenantSlug, inspectionId, c.env.JWT_SECRET); + // The version travels into the render so the frozen PDF renders what + // that version FROZE. Before this the URL named no version, so the + // "frozen" artifact was whatever the live page said the first time + // somebody asked for it — which may be long after publication. The + // freezing was a caching side effect, not a property of the document. + const reportUrl = await buildRenderReportUrl(getBookingHost(c), tenantSlug, inspectionId, c.env.JWT_SECRET, versionNumber); // Use contentHash from the snapshot row if available; fall back to live hash // so even legacy rows (no contentHash) get a rendered PDF. diff --git a/server/lib/public-urls.ts b/server/lib/public-urls.ts index 94f4ab7f7..d937606fd 100644 --- a/server/lib/public-urls.ts +++ b/server/lib/public-urls.ts @@ -41,9 +41,13 @@ export function reportUrl(host: string, tenantSlug: string, inspectionId: string */ export async function buildRenderReportUrl( host: string, tenantSlug: string, inspectionId: string, secret: string, + versionNumber?: number, ): Promise { const base = reportUrl(host, tenantSlug, inspectionId); // no query - const token = await signRenderToken(inspectionId, secret); + // `versionNumber` names a PUBLISHED version, and the page then renders that + // version's snapshot instead of live tables. It travels inside the signed + // render token, never as a query param — see render-token.ts. + const token = await signRenderToken(inspectionId, secret, undefined, versionNumber); return `${base}?render=${encodeURIComponent(token)}`; } diff --git a/server/lib/render-token.ts b/server/lib/render-token.ts index d28300c92..7c8aa3b28 100644 --- a/server/lib/render-token.ts +++ b/server/lib/render-token.ts @@ -34,14 +34,24 @@ async function hmacB64(secret: string, msg: string): Promise { return base64Url(new Uint8Array(sig)); } -interface RenderPayload { i: string; e: number; } // inspectionId, exp epoch ms +// inspectionId, exp epoch ms, and — when the caller is rendering a SPECIFIC +// published version — that version number. `v` rides inside the HMAC body +// rather than as a query param precisely because it changes what the page +// renders: a client who could append `&v=1` to a report link could ask the +// renderer for a version they were never sent. +interface RenderPayload { i: string; e: number; v?: number; } const DEFAULT_TTL_MS = 10 * 60 * 1000; export async function signRenderToken( inspectionId: string, secret: string, ttlMs: number = DEFAULT_TTL_MS, + versionNumber?: number, ): Promise { - const payload: RenderPayload = { i: inspectionId, e: Date.now() + ttlMs }; + const payload: RenderPayload = { + i: inspectionId, + e: Date.now() + ttlMs, + ...(typeof versionNumber === 'number' ? { v: versionNumber } : {}), + }; const body64 = base64Url(encoder.encode(JSON.stringify(payload))); const sig = await hmacB64(secret, body64); return `${body64}.${sig}`; @@ -49,7 +59,7 @@ export async function signRenderToken( export async function verifyRenderToken( token: string, secret: string, -): Promise<{ inspectionId: string } | null> { +): Promise<{ inspectionId: string; versionNumber?: number } | null> { if (!token || typeof token !== 'string') return null; const parts = token.split('.'); if (parts.length !== 2) return null; @@ -62,5 +72,8 @@ export async function verifyRenderToken( try { payload = JSON.parse(base64UrlDecode(body64)) as RenderPayload; } catch { return null; } if (!payload || typeof payload.i !== 'string' || typeof payload.e !== 'number') return null; if (payload.e < Date.now()) return null; - return { inspectionId: payload.i }; + return { + inspectionId: payload.i, + ...(typeof payload.v === 'number' ? { versionNumber: payload.v } : {}), + }; } diff --git a/server/lib/report-snapshot.ts b/server/lib/report-snapshot.ts new file mode 100644 index 000000000..67003a55b --- /dev/null +++ b/server/lib/report-snapshot.ts @@ -0,0 +1,44 @@ +import { and, eq } from 'drizzle-orm'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; +import { reportVersions } from './db/schema'; +import type { Snapshot } from './version-diff'; + +/** + * Reading what a published report FROZE. + * + * Its own module rather than a private method on the report service, because it + * belongs to the VERSION track, not the report-assembly track — and the service + * it would otherwise sit in is already 900 lines of assembly. + */ + +/** + * The snapshot for one published version, or null. + * + * TOLERANT IN ONE DIRECTION ONLY. A missing or unparseable row falls back to + * live resolution, because a client holding a per-version link must still get + * their report — a stricter failure would turn a storage problem into a + * customer-facing outage on a document they already own. What it must never do + * is silently serve a DIFFERENT version, so the lookup is exact on + * (tenant, inspection, version) and returns null rather than the nearest match. + */ +export async function loadPinnedSnapshot( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + db: DrizzleD1Database, + tenantId: string, + inspectionId: string, + versionNumber: number, +): Promise { + try { + const row = await db.select({ snapshotJson: reportVersions.snapshotJson }) + .from(reportVersions) + .where(and( + eq(reportVersions.tenantId, tenantId), + eq(reportVersions.inspectionId, inspectionId), + eq(reportVersions.versionNumber, versionNumber), + )).get(); + if (!row?.snapshotJson) return null; + return JSON.parse(row.snapshotJson) as Snapshot; + } catch { + return null; + } +} diff --git a/server/lib/version-diff.ts b/server/lib/version-diff.ts index fe9c1f620..95368472b 100644 --- a/server/lib/version-diff.ts +++ b/server/lib/version-diff.ts @@ -9,10 +9,88 @@ * are skipped on field walks so version bumps don't show as changes. */ +/** + * One inspector as the report presents them, with the credentials they held on + * publish day. + * + * A LIST FROM DAY ONE even though only the lead is populated. An inspection can + * have more than one inspector (`leadInspectorId` + `helperInspectorIds`), and + * the report shows a single name today. Which of them the cover should credit is + * a product question, not this change's; what this change fixes is that the + * answer must not cost a migration of every stored snapshot to revisit. A scalar + * that becomes a list AFTER snapshots exist is exactly that migration. Getting + * the shape right while the field is empty is free. + * + * A badge is a claim about a PERSON on a document about an INSPECTION, so the + * role travels with it — an unattributed pool of five badges would turn a + * per-person claim into a per-inspection one that nobody made. + */ +export interface SnapshotInspector { + userId: string; + name: string | null; + role: 'lead' | 'helper'; + credentials: Array<{ label: string; memberNumber: string | null; imageUrl: string | null }>; +} + +/** + * SNAPSHOT SCHEMA VERSIONS + * + * 1 — `{ inspection, data, units }`. Every row written before the credential + * snapshot. Absent `schemaVersion` means 1; the field did not exist. + * 2 — adds `inspectors` and `styleProfile`. + * + * The version exists so a READER can tell "this report predates credentials" + * from "this inspector held none" — two states that look identical as an empty + * array and mean opposite things on a cover page. + * + * It is NOT a hashing basis. `report_versions.content_hash` is the SHA-256 of + * the stored `snapshot_json` STRING, and `verifyByToken` recomputes it from that + * same stored column — so a row written under v1 keeps hashing to exactly what + * it hashed to, whatever later versions contain. Growing this type cannot + * invalidate a signature that already exists, and no dual-basis verifier is + * needed. `report-version-service.spec.ts` pins that directly, because it is the + * kind of reasoning that is easy to get wrong in the safe direction and + * expensive to get wrong in the other. + */ +export const SNAPSHOT_SCHEMA_VERSION = 2; + +/** + * The credentials a PINNED version should render, or null when the snapshot + * cannot answer. + * + * Null and `[]` are different answers and the caller must not conflate them: + * + * null — this snapshot predates the credential capture (schema v1), so there + * is nothing recorded and the live state is the only thing there is to + * show. Those reports WERE rendered live when they were delivered; + * pretending otherwise would be inventing history rather than + * recording it. + * [] — the inspector held no credentials on publish day. A real answer, and + * rendering live state over it would resurrect badges the delivered + * document never carried. + * + * OPTION A on the cover: the LEAD's badges only, matching the report's single + * inspector name and single signer. The snapshot keeps the helpers' too, so + * crediting them later is a rendering decision rather than a migration. + */ +export function pinnedLeadCredentials( + snapshot: Snapshot | null | undefined, +): SnapshotInspector['credentials'] | null { + if (!snapshot?.inspectors) return null; + const lead = snapshot.inspectors.find((i) => i.role === 'lead') ?? snapshot.inspectors[0]; + return lead?.credentials ?? []; +} + export interface Snapshot { + /** Absent on rows written before the credential snapshot — treat as 1. */ + schemaVersion?: number; inspection?: Record; data: Record>; units: Array<{ id: string; [key: string]: unknown }>; + /** v2+. The people the report credits, and what they held on publish day. */ + inspectors?: SnapshotInspector[]; + /** v2+. The appearance profile resolved at publish (Report Style Presets). */ + styleProfile?: Record | null; } interface ItemDiff { diff --git a/server/services/inspection.service.ts b/server/services/inspection.service.ts index cf24d59b9..5994dccf0 100644 --- a/server/services/inspection.service.ts +++ b/server/services/inspection.service.ts @@ -432,8 +432,10 @@ export class InspectionService { makePhotoUrl: (key: string) => string = (key) => `/api/inspections/${inspectionId}/photo?key=${encodeURIComponent(key)}`, videoCtx?: ReportMediaContext, + /** Renders the named published version's snapshot instead of live state. */ + versionNumber?: number, ) { - return this.report.getReportData(inspectionId, tenantId, makePhotoUrl, videoCtx); + return this.report.getReportData(inspectionId, tenantId, makePhotoUrl, videoCtx, versionNumber); } /** diff --git a/server/services/inspection/inspection-report.service.ts b/server/services/inspection/inspection-report.service.ts index f430398b4..7883dd6f9 100644 --- a/server/services/inspection/inspection-report.service.ts +++ b/server/services/inspection/inspection-report.service.ts @@ -1,6 +1,9 @@ import { drizzle } from 'drizzle-orm/d1'; import { eq, and, desc, asc } from 'drizzle-orm'; -import { inspections, inspectionResults, templates, users, tenantConfigs, reportVersions, inspectionUnits, inspectorCredentials } from '../../lib/db/schema'; +import { inspections, inspectionResults, templates, users, tenantConfigs, reportVersions, inspectionUnits } from '../../lib/db/schema'; +import { CredentialService } from '../credential.service'; +import { pinnedLeadCredentials } from '../../lib/version-diff'; +import { loadPinnedSnapshot } from '../../lib/report-snapshot'; import { buildUnitConditionMatrix, defectCountsByUnit } from '../../lib/unit-scope'; import { Errors } from '../../lib/errors'; import { resolveTenantTimeZone } from '../../lib/tz'; @@ -105,9 +108,25 @@ export class InspectionReportService extends InspectionSubService { // report + PDF render chain can branch. Absent (legacy callers) ⇒ photos // resolve exactly as before (image only). videoCtx?: ReportMediaContext, + /** + * When set, this read is serving a SPECIFIC published version, and the + * fields the snapshot captured are taken from it rather than resolved + * live. Only a signed render token can name one (render-token.ts), so a + * link holder cannot ask for a version they were never sent. + */ + versionNumber?: number, ) { const db = this.getDrizzle(); + // Spec B §1 — a published version renders what it FROZE. Loaded up front + // so the live resolutions below can be overlaid rather than duplicated: + // everything the snapshot does not carry still comes from live tables, + // which is the honest scope of this change and is stated on the payload + // itself via `snapshotSchemaVersion`. + const pinned = typeof versionNumber === 'number' + ? await loadPinnedSnapshot(this.getDrizzle(), tenantId, inspectionId, versionNumber) + : null; + const inspection = await db.select().from(inspections) .where(and(eq(inspections.id, inspectionId), eq(inspections.tenantId, tenantId))) .get(); @@ -466,18 +485,25 @@ export class InspectionReportService extends InspectionSubService { inspectorLicense = inspector?.licenseNumber ?? null; } - // Inspector Credentials & Association Badges (Spec B) — the inspector's - // active credentials, resolved to public asset URLs and snapshotted into - // the report payload. Empty rows (no image, blank label) are dropped. - let credentialSnapshot: Array<{ label: string; memberNumber: string | null; imageUrl: string | null }> = []; - if (inspection.inspectorId) { - const credRows = await db.select().from(inspectorCredentials) - .where(and(eq(inspectorCredentials.tenantId, tenantId), eq(inspectorCredentials.userId, inspection.inspectorId), eq(inspectorCredentials.active, true))) - .orderBy(asc(inspectorCredentials.sortOrder), asc(inspectorCredentials.createdAt)).all(); - credentialSnapshot = credRows - .filter((c) => c.imageR2Key || (c.label ?? '').trim()) - .map((c) => ({ label: c.label, memberNumber: c.memberNumber, imageUrl: c.imageR2Key ? `/api/public/brand-asset?key=${encodeURIComponent(c.imageR2Key)}` : null })); - } + // Inspector Credentials & Association Badges (Spec B). + // + // A PINNED VERSION RENDERS WHAT IT FROZE. Resolving these live — which is + // what happened before the snapshot carried them — meant an inspector who + // left an association silently rewrote the cover of every report they had + // ever delivered. Option A on the cover: only the LEAD's badges render, + // matching the report's single inspector name and single signer. The + // snapshot keeps the helpers' too, so crediting them later is a rendering + // decision rather than a migration. + // `null` means the snapshot cannot answer (v1, or no version pinned) and + // live is the only thing there is to show; `[]` means the inspector held + // none on publish day, and rendering live over it would resurrect badges + // the delivered document never carried. + const frozenCredentials = pinnedLeadCredentials(pinned); + const credentialSnapshot: Array<{ label: string; memberNumber: string | null; imageUrl: string | null }> = + frozenCredentials + ?? (inspection.inspectorId + ? await new CredentialService(this.db).listRenderable(tenantId, inspection.inspectorId) + : []); // Sprint 2 S2-4 — per-tenant flag controls whether the published // report renders "Estimated cost: $X – $Y" badges on defect cards. @@ -526,7 +552,10 @@ export class InspectionReportService extends InspectionSubService { } // Report Style Presets (Plan 1a) — three-tier resolution + field-level tweaks. const insp = inspection as { profileOverride?: string | null; badgeLayoutOverride?: string | null; reportPhotoColumns?: number | null }; - const styleProfile = resolveProfile( + // Same rule as the badges: a pinned version keeps the appearance it was + // published under, so a tenant switching their house style cannot + // restyle documents that were already delivered. + const styleProfile = (pinned?.styleProfile as ReturnType | undefined) ?? resolveProfile( { profileOverride: insp.profileOverride ?? null, badgeLayoutOverride: insp.badgeLayoutOverride ?? null, reportPhotoColumns: insp.reportPhotoColumns ?? null }, template ? { defaultProfileId: (template as { defaultProfileId?: string | null }).defaultProfileId ?? null } : null, { defaultProfileId: tenantDefaultProfileId }, diff --git a/server/services/report-version.service.ts b/server/services/report-version.service.ts index 21e709644..81addff8a 100644 --- a/server/services/report-version.service.ts +++ b/server/services/report-version.service.ts @@ -11,8 +11,10 @@ */ import { drizzle } from 'drizzle-orm/d1'; import { and, eq, desc } from 'drizzle-orm'; -import { reportVersions, inspections, inspectionResults, inspectionUnits } from '../lib/db/schema'; -import { computeDiff, type Snapshot, type DiffPayload } from '../lib/version-diff'; +import { reportVersions, inspections, inspectionResults, inspectionUnits, users, inspectionInspectors, templates, tenantConfigs } from '../lib/db/schema'; +import { computeDiff, SNAPSHOT_SCHEMA_VERSION, type Snapshot, type SnapshotInspector, type DiffPayload } from '../lib/version-diff'; +import { CredentialService } from './credential.service'; +import { resolveProfile } from '../lib/report-style/resolve'; import { SigningKeyService, sha256Hex, base64UrlEncode, base64UrlDecode } from './signing-key.service'; const MAX_SNAPSHOT_BYTES = 1024 * 1024; // 1 MB @@ -71,9 +73,18 @@ export class ReportVersionService { .all(); const snapshot: Snapshot = { + schemaVersion: SNAPSHOT_SCHEMA_VERSION, inspection: ins as unknown as Record, data, units, + // Spec B §1: report surfaces snapshot credentials + resolved layout + // at publish; live surfaces read current state. Before this, the + // report resolved credentials LIVE on every read — so an inspector + // who left an association silently rewrote the cover of every report + // they had ever delivered, including ones a client downloaded months + // earlier and may be relying on. + inspectors: await this.resolveInspectors(tenantId, inspectionId, ins), + styleProfile: await this.resolveStyleProfile(tenantId, ins), }; const snapshotJson = JSON.stringify(snapshot); if (snapshotJson.length > MAX_SNAPSHOT_BYTES) { @@ -112,6 +123,94 @@ export class ReportVersionService { return { versionNumber: nextVersion, ...(summary ? { summary } : {}) }; } + /** + * Everyone the report credits, and the credentials they held right now. + * + * OPTION A ON THE COVER, A LIST IN THE PAYLOAD. Only the lead's badges are + * rendered today, matching the report's single inspector name and single + * signer — a helper holding the certification gets no credit, which is + * acceptable only while the line reads "Lead inspector". Both are captured + * here regardless, because deciding otherwise later must not mean migrating + * every snapshot that already exists. + * + * `inspection_inspectors` is the query face over `leadInspectorId` + + * `helperInspectorIds`; when it holds nothing (older inspections that never + * synced) the inspection's own `inspectorId` is the lead. + */ + private async resolveInspectors( + tenantId: string, + inspectionId: string, + ins: Record, + ): Promise { + const db = this.getDrizzle(); + const links = await db.select({ userId: inspectionInspectors.userId, role: inspectionInspectors.role }) + .from(inspectionInspectors) + .where(and( + eq(inspectionInspectors.tenantId, tenantId), + eq(inspectionInspectors.inspectionId, inspectionId), + )).all(); + + const assignments: Array<{ userId: string; role: 'lead' | 'helper' }> = links.length + ? links.map((l) => ({ userId: l.userId, role: l.role })) + : (typeof ins.inspectorId === 'string' && ins.inspectorId + ? [{ userId: ins.inspectorId, role: 'lead' as const }] + : []); + if (!assignments.length) return []; + + // Lead first, so a reader of the raw snapshot sees the same order the + // cover does rather than whatever the link table happened to return. + assignments.sort((a, b) => (a.role === 'lead' ? -1 : 1) - (b.role === 'lead' ? -1 : 1)); + + const credentials = new CredentialService(this.db); + const out: SnapshotInspector[] = []; + for (const a of assignments) { + const u = await db.select({ name: users.name, email: users.email }) + .from(users).where(and(eq(users.id, a.userId), eq(users.tenantId, tenantId))).get(); + out.push({ + userId: a.userId, + name: u?.name || (u?.email?.split('@')[0] ?? null), + role: a.role, + // The shared mapper, so the badge URL in a snapshot and the badge + // URL on the live page cannot disagree about their form. + credentials: await credentials.listRenderable(tenantId, a.userId), + }); + } + return out; + } + + /** + * The appearance profile as resolved on publish day. + * + * Same three-tier resolution the report read path runs + * (inspection override -> template default -> tenant default), captured so a + * tenant switching their house style later does not restyle documents that + * were already delivered. + */ + private async resolveStyleProfile( + tenantId: string, + ins: Record, + ): Promise | null> { + const db = this.getDrizzle(); + let templateDefault: string | null = null; + if (typeof ins.templateId === 'string' && ins.templateId) { + const t = await db.select({ defaultProfileId: templates.defaultProfileId }) + .from(templates).where(and(eq(templates.id, ins.templateId), eq(templates.tenantId, tenantId))).get(); + templateDefault = t?.defaultProfileId ?? null; + } + const cfg = await db.select({ defaultProfileId: tenantConfigs.defaultProfileId }) + .from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); + + return resolveProfile( + { + profileOverride: (ins.profileOverride as string | null) ?? null, + badgeLayoutOverride: (ins.badgeLayoutOverride as string | null) ?? null, + reportPhotoColumns: (ins.reportPhotoColumns as number | null) ?? null, + }, + { defaultProfileId: templateDefault }, + { defaultProfileId: cfg?.defaultProfileId ?? null }, + ) as unknown as Record; + } + async verifyByToken(token: string) { const db = this.getDrizzle(); const row = await db.select().from(reportVersions) diff --git a/tests/unit/reports/public-report-endpoint.spec.ts b/tests/unit/reports/public-report-endpoint.spec.ts index 77936cddc..496be90db 100644 --- a/tests/unit/reports/public-report-endpoint.spec.ts +++ b/tests/unit/reports/public-report-endpoint.spec.ts @@ -63,7 +63,12 @@ describe('GET /api/public/report/:tenant/:id — ③-A.1', () => { const res = await app.request('/api/public/report/t/insp1?token=kvtok'); expect(res.status).toBe(200); // Third arg: the makePhotoUrl factory added by A-9 (photo serve routes). - expect(getReportData).toHaveBeenCalledWith('insp1', 't9', expect.any(Function), expect.any(Object)); + expect(getReportData).toHaveBeenCalledWith('insp1', 't9', expect.any(Function), expect.any(Object), + // 5th arg: the pinned version. `undefined` on every path that is not + // materialising a specific published version — asserted rather than + // omitted, because a NUMBER leaking in here would mean an ordinary + // client read was being served a frozen snapshot. + undefined); }); it('200 with report data + queries by the token tenantId (not the URL)', async () => { @@ -73,7 +78,12 @@ describe('GET /api/public/report/:tenant/:id — ③-A.1', () => { const body = await res.json() as { success: boolean; data: unknown }; expect(body.success).toBe(true); // Third arg: the makePhotoUrl factory added by A-9 (photo serve routes). - expect(getReportData).toHaveBeenCalledWith('insp1', 't1', expect.any(Function), expect.any(Object)); + expect(getReportData).toHaveBeenCalledWith('insp1', 't1', expect.any(Function), expect.any(Object), + // 5th arg: the pinned version. `undefined` on every path that is not + // materialising a specific published version — asserted rather than + // omitted, because a NUMBER leaking in here would mean an ordinary + // client read was being served a frozen snapshot. + undefined); }); }); diff --git a/tests/unit/reports/report-access-gate.spec.ts b/tests/unit/reports/report-access-gate.spec.ts index aec310cd7..86dbd9849 100644 --- a/tests/unit/reports/report-access-gate.spec.ts +++ b/tests/unit/reports/report-access-gate.spec.ts @@ -131,7 +131,12 @@ describe('GET /api/public/report/:tenant/:id — publish gate', () => { const { app, getReportData } = buildApp(); const res = await app.request(`/api/public/report/acme/${INSP_ID}?token=tok`); expect(res.status).toBe(200); - expect(getReportData).toHaveBeenCalledWith(INSP_ID, TENANT_ID, expect.any(Function), expect.any(Object)); + expect(getReportData).toHaveBeenCalledWith(INSP_ID, TENANT_ID, expect.any(Function), expect.any(Object), + // 5th arg: the pinned version. `undefined` on every path that is not + // materialising a specific published version — asserted rather than + // omitted, because a NUMBER leaking in here would mean an ordinary + // client read was being served a frozen snapshot. + undefined); }); it('owner-preview bypasses the gate (200 even when report_status=in_progress)', async () => { @@ -146,7 +151,12 @@ describe('GET /api/public/report/:tenant/:id — publish gate', () => { headers: { Authorization: `Bearer ${ownerJwt}` }, }); expect(res.status).toBe(200); - expect(getReportData).toHaveBeenCalledWith(INSP_ID, TENANT_ID, expect.any(Function), expect.any(Object)); + expect(getReportData).toHaveBeenCalledWith(INSP_ID, TENANT_ID, expect.any(Function), expect.any(Object), + // 5th arg: the pinned version. `undefined` on every path that is not + // materialising a specific published version — asserted rather than + // omitted, because a NUMBER leaking in here would mean an ordinary + // client read was being served a frozen snapshot. + undefined); }); }); diff --git a/tests/unit/reports/report-pinned-version-read.spec.ts b/tests/unit/reports/report-pinned-version-read.spec.ts new file mode 100644 index 000000000..0039a7343 --- /dev/null +++ b/tests/unit/reports/report-pinned-version-read.spec.ts @@ -0,0 +1,132 @@ +/** + * Reading a PINNED published version. + * + * Two separate claims, and they fail in different ways: + * + * 1. The version travels inside the SIGNED render token, never as a query + * param. A link holder who could append `&v=1` to a report URL would be + * asking the renderer for a version they were never sent. + * 2. When a version is named, the fields the snapshot captured come from it. + * Before this, the "frozen" per-version PDF was rendered from the LIVE page + * the first time somebody downloaded it — so the freezing was a caching + * side effect, and a v3 PDF nobody had fetched would be generated from data + * as it stood whenever they got round to it. + */ +import { describe, it, expect } from 'vitest'; +import { signRenderToken, verifyRenderToken } from '../../../server/lib/render-token'; +import { buildRenderReportUrl } from '../../../server/lib/public-urls'; +import { pinnedLeadCredentials, type Snapshot } from '../../../server/lib/version-diff'; + +const SECRET = 'test-secret'; +const INSPECTION = 'insp-1'; + +describe('render token — the pinned version claim', () => { + it('round-trips a version number', async () => { + const t = await signRenderToken(INSPECTION, SECRET, undefined, 3); + expect(await verifyRenderToken(t, SECRET)).toEqual({ inspectionId: INSPECTION, versionNumber: 3 }); + }); + + it('omits the claim entirely when no version is named', async () => { + const t = await signRenderToken(INSPECTION, SECRET); + const v = await verifyRenderToken(t, SECRET); + // Absent, not zero and not null: `typeof v === 'number'` is what decides + // between the snapshot and live resolution downstream. + expect(v).toEqual({ inspectionId: INSPECTION }); + expect('versionNumber' in v!).toBe(false); + }); + + it('cannot be forged or edited — the version is inside the HMAC body', async () => { + const real = await signRenderToken(INSPECTION, SECRET, undefined, 1); + const [body, sig] = real.split('.'); + + // Re-encode the body with a different version, keeping the signature. + const decoded = JSON.parse(atob(body.replace(/-/g, '+').replace(/_/g, '/') + + '='.repeat((4 - (body.length % 4)) % 4))) as Record; + decoded.v = 99; + const forgedBody = btoa(JSON.stringify(decoded)) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + + expect(await verifyRenderToken(`${forgedBody}.${sig}`, SECRET)).toBeNull(); + }); + + it('a token signed with another secret names nothing', async () => { + const t = await signRenderToken(INSPECTION, 'someone-elses-secret', undefined, 2); + expect(await verifyRenderToken(t, SECRET)).toBeNull(); + }); +}); + +describe('buildRenderReportUrl', () => { + it('puts the version in the TOKEN, not the query string', async () => { + const url = await buildRenderReportUrl('app.test', 'acme', INSPECTION, SECRET, 4); + // The whole point: nothing in the visible URL says "4", so nothing in + // the visible URL can be edited to say "5". + expect(url).not.toContain('v=4'); + expect(url).not.toContain('version'); + const token = decodeURIComponent(new URL(url).searchParams.get('render')!); + expect(await verifyRenderToken(token, SECRET)).toMatchObject({ versionNumber: 4 }); + }); + + it('mints an unpinned token when no version is given', async () => { + const url = await buildRenderReportUrl('app.test', 'acme', INSPECTION, SECRET); + const token = decodeURIComponent(new URL(url).searchParams.get('render')!); + const v = await verifyRenderToken(token, SECRET); + expect('versionNumber' in v!).toBe(false); + }); +}); + +/** + * Which credentials a pinned read renders. + * + * NULL AND `[]` ARE DIFFERENT ANSWERS, and conflating them is the whole hazard: + * one means "this report predates the capture, live state is all there is", the + * other means "the inspector held none on publish day". As JSON they look + * identical; on a cover page they are opposites, and the wrong one either hides + * a badge a document carried or resurrects one it never did. + */ +describe('pinnedLeadCredentials', () => { + const cred = (label: string) => ({ label, memberNumber: null, imageUrl: null }); + const snap = (inspectors?: Snapshot['inspectors']): Snapshot => + ({ data: {}, units: [], ...(inspectors ? { inspectors } : {}) }); + + it('returns null when nothing is pinned at all', () => { + expect(pinnedLeadCredentials(null)).toBeNull(); + expect(pinnedLeadCredentials(undefined)).toBeNull(); + }); + + it('returns null for a v1 snapshot, so live fills in', () => { + // Those reports WERE rendered live when they were delivered. Serving an + // empty strip instead would be inventing history, not recording it. + expect(pinnedLeadCredentials(snap())).toBeNull(); + }); + + it('returns an EMPTY LIST when the lead held none — not null', () => { + // The distinction that stops live state leaking back into a frozen + // document. `?? live` on a null is the fallback; `?? live` on `[]` is not. + const out = pinnedLeadCredentials(snap([ + { userId: 'u1', name: 'Dana', role: 'lead', credentials: [] }, + ])); + expect(out).toEqual([]); + expect(out).not.toBeNull(); + }); + + it('renders the LEAD only, whatever order the inspectors are in', () => { + const out = pinnedLeadCredentials(snap([ + { userId: 'u2', name: 'Sam', role: 'helper', credentials: [cred('Helper cert')] }, + { userId: 'u1', name: 'Dana', role: 'lead', credentials: [cred('Lead cert')] }, + ])); + // Option A. Pooling both would put an unattributed claim on the cover + // that neither person made. + expect(out!.map((c) => c.label)).toEqual(['Lead cert']); + }); + + it('falls back to the first inspector when no one is marked lead', () => { + const out = pinnedLeadCredentials(snap([ + { userId: 'u2', name: 'Sam', role: 'helper', credentials: [cred('Only cert')] }, + ])); + expect(out!.map((c) => c.label)).toEqual(['Only cert']); + }); + + it('survives an inspectors array that is present but empty', () => { + expect(pinnedLeadCredentials(snap([]))).toEqual([]); + }); +}); diff --git a/tests/unit/reports/report-version-snapshot.spec.ts b/tests/unit/reports/report-version-snapshot.spec.ts new file mode 100644 index 000000000..387d74ba7 --- /dev/null +++ b/tests/unit/reports/report-version-snapshot.spec.ts @@ -0,0 +1,237 @@ +/** + * What a published report FROZE, and what a later edit can no longer reach. + * + * The report resolved credentials LIVE on every read, so an inspector who left + * an association silently rewrote the cover of every report they had ever + * delivered — including ones a client downloaded months earlier and may be + * relying on. Snapshotting at publish is what makes a delivered document a + * document (Spec B §1: report surfaces snapshot at publish; live surfaces read + * current state). + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ReportVersionService } from '../../../server/services/report-version.service'; +import { createTestDb, setupSchema } from '../db'; +import * as schema from '../../../server/lib/db/schema'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; + +const TENANT = '00000000-0000-0000-0000-000000000099'; +const INSPECTION = '11111111-1111-1111-1111-111111111111'; +const LEAD = 'user-lead'; +const HELPER = 'user-helper'; + +async function seed(db: BetterSQLite3Database) { + await db.insert(schema.tenants).values({ + id: TENANT, name: 'Acme', slug: 'acme', status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + }); + await db.insert(schema.inspections).values({ + id: INSPECTION, tenantId: TENANT, propertyAddress: '1 Main St', date: '2026-06-01', + status: 'requested', paymentStatus: 'unpaid', price: 0, + paymentRequired: false, agreementRequired: false, createdAt: new Date(), + }); +} + +describe('snapshotOnPublish — credentials and appearance', () => { + let svc: ReportVersionService; + let db: BetterSQLite3Database; + + const snapshotOf = async (version = 1) => { + const row = await db.select().from(schema.reportVersions) + .where(eq(schema.reportVersions.versionNumber, version)).get(); + return JSON.parse(row!.snapshotJson) as Record; + }; + + beforeEach(async () => { + const fix = createTestDb(); + db = fix.db; + await setupSchema(fix.sqlite); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockDrizzle as any).mockReturnValue(db); + await seed(db); + svc = new ReportVersionService({} as D1Database, 'test-encryption-secret-key'); + + for (const [id, name] of [[LEAD, 'Dana Lead'], [HELPER, 'Sam Helper']] as const) { + await db.insert(schema.users).values({ + id, tenantId: TENANT, email: id + '@acme.test', name, + passwordHash: 'x', role: 'inspector', createdAt: new Date(), + }); + } + await db.update(schema.inspections).set({ inspectorId: LEAD }) + .where(eq(schema.inspections.id, INSPECTION)); + await db.insert(schema.inspectionInspectors).values([ + { inspectionId: INSPECTION, userId: LEAD, tenantId: TENANT, role: 'lead', createdAt: new Date() }, + { inspectionId: INSPECTION, userId: HELPER, tenantId: TENANT, role: 'helper', createdAt: new Date() }, + ]); + await db.insert(schema.inspectorCredentials).values([ + { id: 'c1', tenantId: TENANT, userId: LEAD, label: 'InterNACHI CPI', memberNumber: 'N-1', + imageR2Key: 't/cred/logo.png', sortOrder: 0, active: true, createdAt: new Date(), updatedAt: new Date() }, + { id: 'c2', tenantId: TENANT, userId: HELPER, label: 'Radon Certified', memberNumber: 'R-9', + imageR2Key: null, sortOrder: 0, active: true, createdAt: new Date(), updatedAt: new Date() }, + ]); + }); + + it('captures the credentials each inspector held, keyed to the PERSON', async () => { + await svc.snapshotOnPublish(TENANT, INSPECTION, 'user-a'); + const inspectors = (await snapshotOf()).inspectors as Array>; + + // A badge is a claim about a person on a document about an inspection. + // Pooling them would turn a per-person claim into a per-inspection one + // that nobody made, so the role travels with the credentials. + expect(inspectors.map((i) => i.role)).toEqual(['lead', 'helper']); + expect(inspectors[0].userId).toBe(LEAD); + expect(inspectors[0].name).toBe('Dana Lead'); + expect((inspectors[0].credentials as Array<{ label: string }>).map((c) => c.label)) + .toEqual(['InterNACHI CPI']); + expect((inspectors[1].credentials as Array<{ label: string }>).map((c) => c.label)) + .toEqual(['Radon Certified']); + }); + + it('is a LIST even with one inspector, so crediting helpers later costs no migration', async () => { + await db.delete(schema.inspectionInspectors) + .where(eq(schema.inspectionInspectors.userId, HELPER)); + await svc.snapshotOnPublish(TENANT, INSPECTION, 'user-a'); + const inspectors = (await snapshotOf()).inspectors; + expect(Array.isArray(inspectors)).toBe(true); + expect(inspectors).toHaveLength(1); + }); + + it('falls back to the inspection own lead when nothing is linked', async () => { + await db.delete(schema.inspectionInspectors); + await svc.snapshotOnPublish(TENANT, INSPECTION, 'user-a'); + const inspectors = (await snapshotOf()).inspectors as Array>; + expect(inspectors).toHaveLength(1); + expect(inspectors[0].userId).toBe(LEAD); + expect(inspectors[0].role).toBe('lead'); + }); + + it('KEEPS the old badge after the inspector drops the association', async () => { + // The assertion the whole snapshot exists for, and the one nobody writes. + await svc.snapshotOnPublish(TENANT, INSPECTION, 'user-a'); + await db.update(schema.inspectorCredentials).set({ active: false }) + .where(eq(schema.inspectorCredentials.id, 'c1')); + + const frozen = (await snapshotOf()).inspectors as Array<{ credentials: unknown[] }>; + expect(frozen[0].credentials).toHaveLength(1); + + // ...while a NEW publish reflects the change, because that is a new + // document. Snapshot and live are supposed to differ; that is the point. + await svc.snapshotOnPublish(TENANT, INSPECTION, 'user-a'); + const fresh = (await snapshotOf(2)).inspectors as Array<{ credentials: unknown[] }>; + expect(fresh[0].credentials).toHaveLength(0); + }); + + it('captures the RESOLVED appearance profile, not the id it came from', async () => { + await svc.snapshotOnPublish(TENANT, INSPECTION, 'user-a'); + const style = (await snapshotOf()).styleProfile as Record; + // Resolved, so a tenant switching their house style later cannot + // restyle a document that was already delivered. + expect(style).toBeTruthy(); + expect(typeof style.badgeLayout).toBe('string'); + expect(typeof style.photoColumns).toBe('number'); + }); + + it('stamps the schema version, so a reader can tell absent from empty', async () => { + await svc.snapshotOnPublish(TENANT, INSPECTION, 'user-a'); + // An empty `inspectors` on a v2 row means "held none"; a missing one on + // a v1 row means "this predates the feature". Identical as JSON, + // opposite as a claim on a cover page. + expect((await snapshotOf()).schemaVersion).toBe(2); + }); + + it('never reaches into another tenant credentials', async () => { + await db.insert(schema.inspectorCredentials).values({ + id: 'c3', tenantId: 'other-tenant', userId: LEAD, label: 'Not ours', + memberNumber: null, imageR2Key: null, sortOrder: 0, active: true, + createdAt: new Date(), updatedAt: new Date(), + }); + await svc.snapshotOnPublish(TENANT, INSPECTION, 'user-a'); + const inspectors = (await snapshotOf()).inspectors as Array<{ credentials: Array<{ label: string }> }>; + expect(inspectors[0].credentials.map((c) => c.label)).toEqual(['InterNACHI CPI']); + }); +}); + +/** + * Growing the snapshot must not invalidate a signature that already exists. + * + * This looked like it needed a dual-basis verifier — old rows hashed one way, + * new rows another, and something to tell them apart. It does not, and the + * reason is worth pinning rather than re-deriving: `content_hash` is the + * SHA-256 of the stored `snapshot_json` STRING, and `verifyByToken` recomputes + * it from that same stored column. A row written under the old shape keeps + * hashing to exactly what it hashed to, whatever later versions contain. + * + * If that ever stops being true — if the verifier is "simplified" into + * re-serialising a parsed object, or into rebuilding the snapshot from live + * tables — this spec fails. Without it, every report issued before the change + * would quietly start reporting as tampered on its own verification page. + */ +describe('snapshot growth vs. already-signed versions', () => { + let svc: ReportVersionService; + let db: BetterSQLite3Database; + + beforeEach(async () => { + const fix = createTestDb(); + db = fix.db; + await setupSchema(fix.sqlite); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockDrizzle as any).mockReturnValue(db); + await seed(db); + svc = new ReportVersionService({} as D1Database, 'test-encryption-secret-key'); + }); + + it('still verifies a snapshot written in the pre-credentials shape', async () => { + await svc.snapshotOnPublish(TENANT, INSPECTION, 'user-a'); + const row = await db.select().from(schema.reportVersions).get(); + + // Rewrite the row into the old shape and re-sign it the way the old + // code did: hash of the stored string, signature over that hash. + const legacyJson = JSON.stringify({ + inspection: (JSON.parse(row!.snapshotJson) as { inspection: unknown }).inspection, + data: {}, units: [], + }); + const { sha256Hex, SigningKeyService, base64UrlEncode } = + await import('../../../server/services/signing-key.service'); + const legacyHash = await sha256Hex(legacyJson); + const { privateKey } = await new SigningKeyService({} as D1Database, 'test-encryption-secret-key') + .ensureKeypair(TENANT); + const sig = base64UrlEncode(new Uint8Array(await crypto.subtle.sign( + { name: 'Ed25519' }, privateKey, new TextEncoder().encode(legacyHash), + ))); + await db.update(schema.reportVersions) + .set({ snapshotJson: legacyJson, contentHash: legacyHash, signature: sig }) + .where(eq(schema.reportVersions.id, row!.id)); + + const v = await svc.verifyByToken(row!.verificationToken!); + expect(v!.hashValid).toBe(true); + expect(v!.signatureValid).toBe(true); + expect(v!.chainValid).toBe(true); + }); + + it('verifies a new-shape row on its own basis', async () => { + await svc.snapshotOnPublish(TENANT, INSPECTION, 'user-a'); + const row = await db.select().from(schema.reportVersions).get(); + const v = await svc.verifyByToken(row!.verificationToken!); + expect(v!.hashValid).toBe(true); + expect(v!.signatureValid).toBe(true); + }); + + it('still detects tampering with the larger snapshot', async () => { + // Growing the payload must not dilute what the hash is FOR. + await svc.snapshotOnPublish(TENANT, INSPECTION, 'user-a'); + const row = await db.select().from(schema.reportVersions).get(); + const tampered = JSON.parse(row!.snapshotJson) as Record; + (tampered.inspectors as Array<{ credentials: unknown[] }>)[0] = { + credentials: [{ label: 'Board Certified Anything', memberNumber: null, imageUrl: null }], + } as never; + await db.update(schema.reportVersions).set({ snapshotJson: JSON.stringify(tampered) }) + .where(eq(schema.reportVersions.id, row!.id)); + + const v = await svc.verifyByToken(row!.verificationToken!); + expect(v!.hashValid).toBe(false); + expect(v!.signatureValid).toBe(false); + }); +}); From fa310eb0d926265b14fcb0caf018e37eef844246 Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 1 Aug 2026 03:06:15 +0800 Subject: [PATCH 30/48] feat(credentials): the state license becomes a credential row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `users.license_number` predates `inspector_credentials` and is still the only source of the license line on two surfaces — the email signature footer and the PDF footer. Retiring the column is the next step; this one makes the data exist in the new shape FIRST, so those surfaces can move over without a window in which an inspector's license silently vanishes from a document. `sort_order = -1`, not 0. The state license is the one credential with legal weight, and letting it land wherever insertion order puts it among voluntary association logos is the wrong answer even though it looks cosmetic. IDEMPOTENT, and the guard keys on the NUMBER rather than the label — an inspector who already typed their license in under their own wording must not end up with two of them. Removing the guard turns two specs red. The spec runs the REAL migration SQL rather than a reimplementation, because the thing that can be wrong here is the SQL: a guard that does not guard, a filter that misses soft-deleted users, whitespace that counts as a license. A hand-written equivalent would only test the equivalent. It locates the file by NAME rather than by sequence number, so a squash that renumbers migrations cannot break it for a reason unrelated to the backfill — the same reason `lint:migrefs` forbids those numbers in comments. The column is deliberately NOT dropped here. Backfill, ship, verify the surfaces render, drop later — and per the Schema Rules a retired column is frozen with a comment rather than dropped anyway, since D1 cannot drop a column on an FK-referenced table. Verified against the real local D1: one row after running the file twice. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HcJgdRoQzrRJ8RvEcpUDR5 --- migrations/0022_license_number_backfill.sql | 47 +++++ migrations/meta/_journal.json | 9 +- .../unit/credentials/license-backfill.spec.ts | 164 ++++++++++++++++++ 3 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 migrations/0022_license_number_backfill.sql create mode 100644 tests/unit/credentials/license-backfill.spec.ts diff --git a/migrations/0022_license_number_backfill.sql b/migrations/0022_license_number_backfill.sql new file mode 100644 index 000000000..92ed115c7 --- /dev/null +++ b/migrations/0022_license_number_backfill.sql @@ -0,0 +1,47 @@ +-- Backfill: the state license becomes a credential row. +-- +-- `users.license_number` predates `inspector_credentials` and is still the only +-- source of the license line on two surfaces (the email signature footer and the +-- PDF footer). Retiring the column is a separate, later step; this migration +-- makes the data available in the new shape FIRST, so those surfaces can be +-- moved over without any window in which an inspector's license silently +-- vanishes from a document. +-- +-- sort_order = -1, not 0: the state license is the one credential with legal +-- weight, and it should not land wherever insertion order happens to put it +-- among voluntary association badges. +-- +-- IDEMPOTENT. The NOT EXISTS guard keys on (tenant, user, member_number), so a +-- re-run after a partial failure inserts nothing — the only kind of data +-- migration worth writing for a table this small. +-- +-- Soft-deleted users are skipped: their license is not going on anything. +INSERT INTO inspector_credentials + (id, tenant_id, user_id, label, member_number, image_r2_key, sort_order, is_active, created_at, updated_at) +SELECT + lower( + substr(hex(randomblob(4)), 1, 8) || '-' || + substr(hex(randomblob(2)), 1, 4) || '-4' || + substr(hex(randomblob(2)), 2, 3) || '-a' || + substr(hex(randomblob(2)), 2, 3) || '-' || + substr(hex(randomblob(6)), 1, 12) + ), + u.tenant_id, + u.id, + 'Licensed home inspector', -- the string the old renderer hard-coded + u.license_number, + NULL, -- text-only; a state license has no badge image + -1, + 1, + CAST(strftime('%s', 'now') AS INTEGER) * 1000, + CAST(strftime('%s', 'now') AS INTEGER) * 1000 +FROM users u +WHERE u.license_number IS NOT NULL + AND trim(u.license_number) <> '' + AND u.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM inspector_credentials c + WHERE c.tenant_id = u.tenant_id + AND c.user_id = u.id + AND c.member_number = u.license_number + ); diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 4293c8df6..5859e7062 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1785515513924, "tag": "0021_gigantic_bruce_banner", "breakpoints": true + }, + { + "idx": 22, + "version": "6", + "when": 1785515514924, + "tag": "0022_license_number_backfill", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/tests/unit/credentials/license-backfill.spec.ts b/tests/unit/credentials/license-backfill.spec.ts new file mode 100644 index 000000000..bb9a5a30a --- /dev/null +++ b/tests/unit/credentials/license-backfill.spec.ts @@ -0,0 +1,164 @@ +/** + * The state license becomes a credential row. + * + * This spec executes the REAL migration SQL rather than a reimplementation of + * it, because the thing that can be wrong here is the SQL — a guard that does + * not guard, a filter that misses soft-deleted users, a sort order that puts a + * state license in among voluntary badges. A hand-written equivalent would test + * the equivalent. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { createTestDb, setupSchema } from '../db'; +import * as schema from '../../../server/lib/db/schema'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import type BetterSqlite3 from 'better-sqlite3'; +import { eq } from 'drizzle-orm'; + +/** + * Located by NAME, not by sequence number. Migration numbers are a positional + * token that a squash renumbers, so hard-coding one here would make this spec + * fail for a reason that has nothing to do with the backfill — which is the + * same reason `lint:migrefs` forbids them in comments. + */ +const MIGRATION_DIR = join(process.cwd(), 'migrations'); +const MIGRATION_FILE = readdirSync(MIGRATION_DIR) + .filter((f) => f.endsWith('_license_number_backfill.sql')) + .sort() + .at(-1); +if (!MIGRATION_FILE) throw new Error('license_number backfill migration not found in migrations/'); +const MIGRATION = readFileSync(join(MIGRATION_DIR, MIGRATION_FILE), 'utf8'); + +const T = '00000000-0000-0000-0000-0000000000a1'; +const T2 = '00000000-0000-0000-0000-0000000000a2'; + +describe('license_number backfill migration', () => { + let db: BetterSQLite3Database; + let sqlite: BetterSqlite3.Database; + + const run = () => sqlite.exec(MIGRATION); + const creds = () => db.select().from(schema.inspectorCredentials).all(); + + async function user(id: string, licenseNumber: string | null, opts: { tenantId?: string; deletedAt?: Date } = {}) { + await db.insert(schema.users).values({ + id, tenantId: opts.tenantId ?? T, email: id + '@acme.test', name: id, + passwordHash: 'x', role: 'inspector', licenseNumber, + ...(opts.deletedAt ? { deletedAt: opts.deletedAt } : {}), + createdAt: new Date(), + }); + } + + beforeEach(async () => { + const fix = createTestDb(); + db = fix.db; + sqlite = fix.sqlite; + await setupSchema(fix.sqlite); + for (const id of [T, T2]) { + await db.insert(schema.tenants).values({ + id, name: 'Co ' + id, slug: 'co-' + id.slice(-2), status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + }); + } + }); + + it('creates one text-only credential per licensed inspector', async () => { + await user('u1', 'TX-9001'); + run(); + + const rows = creds(); + expect(rows).toHaveLength(1); + expect(rows[0].userId).toBe('u1'); + expect(rows[0].tenantId).toBe(T); + // The string the old renderer hard-coded, so the line a recipient reads + // does not change wording on the day the source of it does. + expect(rows[0].label).toBe('Licensed home inspector'); + expect(rows[0].memberNumber).toBe('TX-9001'); + expect(rows[0].imageR2Key).toBeNull(); + expect(rows[0].active).toBe(true); + }); + + it('sorts the license BEFORE voluntary badges', async () => { + // -1, not 0. The state license is the one credential with legal weight; + // landing wherever insertion order puts it among association logos is + // the wrong answer even though it looks like a cosmetic one. + await user('u1', 'TX-9001'); + await db.insert(schema.inspectorCredentials).values({ + id: 'c-assoc', tenantId: T, userId: 'u1', label: 'InterNACHI CPI', + memberNumber: 'N-1', imageR2Key: null, sortOrder: 0, active: true, + createdAt: new Date(), updatedAt: new Date(), + }); + run(); + + const rows = creds().sort((a, b) => a.sortOrder - b.sortOrder); + expect(rows.map((r) => r.label)).toEqual(['Licensed home inspector', 'InterNACHI CPI']); + expect(rows[0].sortOrder).toBe(-1); + }); + + it('is IDEMPOTENT — a second run inserts nothing', async () => { + // The only kind of data migration worth writing for a table this small: + // one that can be re-run after a partial failure. + await user('u1', 'TX-9001'); + run(); + run(); + run(); + expect(creds()).toHaveLength(1); + }); + + it('does not duplicate a license the inspector already entered by hand', async () => { + await user('u1', 'TX-9001'); + await db.insert(schema.inspectorCredentials).values({ + id: 'c-manual', tenantId: T, userId: 'u1', label: 'State license', + memberNumber: 'TX-9001', imageR2Key: null, sortOrder: 3, active: true, + createdAt: new Date(), updatedAt: new Date(), + }); + run(); + // The guard keys on the NUMBER, not the label — somebody who typed their + // license in under their own wording must not end up with two of them. + expect(creds()).toHaveLength(1); + expect(creds()[0].id).toBe('c-manual'); + }); + + it('skips users with no license, and blank or whitespace-only ones', async () => { + await user('u-null', null); + await user('u-empty', ''); + await user('u-spaces', ' '); + run(); + expect(creds()).toHaveLength(0); + }); + + it('skips soft-deleted users', async () => { + // Their license is not going on anything. + await user('u-gone', 'TX-DEAD', { deletedAt: new Date() }); + await user('u-live', 'TX-LIVE'); + run(); + expect(creds().map((r) => r.memberNumber)).toEqual(['TX-LIVE']); + }); + + it('keeps each row on its own tenant', async () => { + await user('u1', 'TX-1'); + await user('u2', 'TX-2', { tenantId: T2 }); + run(); + const rows = creds().sort((a, b) => a.tenantId.localeCompare(b.tenantId)); + expect(rows.map((r) => [r.tenantId, r.memberNumber])).toEqual([[T, 'TX-1'], [T2, 'TX-2']]); + }); + + it('gives every row a distinct id', async () => { + for (let i = 0; i < 25; i++) await user('u' + i, 'TX-' + i); + run(); + const ids = creds().map((r) => r.id); + expect(new Set(ids).size).toBe(25); + // UUID-shaped, so these ids read like every other id in the schema. + expect(ids.every((id) => /^[0-9a-f-]{36}$/.test(id))).toBe(true); + }); + + it('leaves users.license_number in place', async () => { + // Deliberately NOT dropped in the same migration: the column is still + // the only source of the license line on the email signature and the PDF + // footer, and D1 cannot drop a column on an FK-referenced table anyway. + await user('u1', 'TX-9001'); + run(); + const u = await db.select().from(schema.users).where(eq(schema.users.id, 'u1')).get(); + expect(u!.licenseNumber).toBe('TX-9001'); + }); +}); From 47b7b053b08ba8751acebf1973255b3cbb61d72c Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 1 Aug 2026 05:59:49 +0800 Subject: [PATCH 31/48] feat(credentials): retire users.license_number; the section is now Licenses & affiliations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps 4 and 5 of the credentials plan, together, because the rename only stops being a lie once the field it collides with is gone. THE FIELD HAD SIX READERS, not the two the plan listed. Alongside the email signature and the PDF footer: the agreement sign-effects block, the booking confirmation footer, the report payload's `inspectorLicense`, and the publish gate. All six now read `CredentialService.primaryLicenseNumber`, which is defined as "first active credential carrying a member number, in the inspector's own order" — and that rule works precisely because the backfill seeded the licence at `sort_order = -1`. That sort order was chosen for this, not for looks. `inspector-signature.ts` no longer renders its hard-coded "Licensed home inspector · " line at all. The licence renders with the other credentials, under the label the backfill gave it, because two sources for one line is how a recipient ends up reading their licence twice. A spec pins the stronger claim that a caller still passing the frozen field gets NOTHING extra — that is the one that matters while any caller lags. `users.license_number` is FROZEN, not dropped: D1 cannot drop a column on an FK-referenced table, and per the Schema Rules a retired column keeps its name forever with a DEAD comment. `RENDER_VERSION` r10 -> r11 so cached PDFs are re-rendered rather than serving a footer built from a column nothing reads. THE RENAME. Both words are industry-standard and neither is ours: Spectora calls its licence box "Credentials", so "Credentials & badges" sitting NEXT TO a "License #" field read as two different things to exactly the users we import from — when the intent was that they are one thing. Inspectors' own sites say "Affiliations". The section heading, the empty state, the add button and the two placeholders all move; the table, the API path and the code identifiers do not, because `inspector_credentials` is accurate and renaming a shipped table buys nothing. The subtitle promised "shown on your reports, emails, and booking page" while two of the three were empty. It is true now — reports since the snapshot work, emails since the send-path fix — so the copy is corrected rather than softened. ⚠️ DEPLOY ORDER IS LOAD-BEARING. Nothing reads `users.license_number` after this, so `db:migrate:remote` (the backfill) MUST run BEFORE the worker deploys. Deploying first leaves every licence line blank — PDF footers, email signatures, agreement blocks — until the migration lands. And per the D1 SOP, that remote migration needs a `d1 export --remote` first, because 0020 on this branch is a hand-edited full-table rebuild. Full API suite green (4134); the one unrelated pre-existing failure is the MCP openapi-snapshot drift check, which this commit regenerates. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HcJgdRoQzrRJ8RvEcpUDR5 --- app/lib/forms/settings.schema.ts | 3 +- app/routes/settings-profile.tsx | 15 +----- messages/en/settings.json | 15 +++--- scripts/file-size-baseline.json | 8 +-- server/api/auth/profile.ts | 4 -- server/api/profile.ts | 8 +-- server/lib/db/schema/tenant/user.ts | 7 +++ server/lib/inspector-signature.ts | 13 +++-- server/lib/mcp/openapi-snapshot.json | 12 +---- server/lib/pdf.ts | 5 +- server/lib/sign-effects.ts | 3 +- server/lib/signature-helpers.ts | 3 -- server/services/booking.service.ts | 1 - server/services/credential.service.ts | 19 +++++++ .../inspection/inspection-publish.service.ts | 11 ++-- .../inspection/inspection-report.service.ts | 23 ++++---- .../agreements/inspector-signature.spec.ts | 27 ++++++++-- tests/unit/credentials/service.spec.ts | 54 +++++++++++++++++++ .../reports/report-signature-payload.spec.ts | 11 +++- 19 files changed, 161 insertions(+), 81 deletions(-) diff --git a/app/lib/forms/settings.schema.ts b/app/lib/forms/settings.schema.ts index 716f5391d..0e7e927a1 100644 --- a/app/lib/forms/settings.schema.ts +++ b/app/lib/forms/settings.schema.ts @@ -78,7 +78,7 @@ export type ChangePasswordInput = z.infer in the UI. diff --git a/app/routes/settings-profile.tsx b/app/routes/settings-profile.tsx index c714a0c1e..169000d00 100644 --- a/app/routes/settings-profile.tsx +++ b/app/routes/settings-profile.tsx @@ -29,7 +29,6 @@ interface Profile { name?: string | null; email?: string | null; phone?: string | null; - licenseNumber?: string | null; // DB-12 / IA-26 — slug omitted; inspector booking slugs are frozen. photoUrl?: string | null; signatureEnabled?: boolean; @@ -194,7 +193,7 @@ export async function action({ request, context }: Route.ActionArgs) { const v = submission.value; const body: Record = {}; // DB-12 / IA-26 — "slug" intentionally removed; inspector booking slugs frozen. - for (const key of ["name", "phone", "licenseNumber"] as const) { + for (const key of ["name", "phone"] as const) { if (v[key] !== undefined) body[key] = v[key]; } // Per-user timezone override. The - {fields.licenseNumber.errors ? ( -

{fields.licenseNumber.errors[0]}

- ) : ( -

{m.settings_profile_license_hint()}

- )} -
diff --git a/messages/en/settings.json b/messages/en/settings.json index 232c0e034..d3026ee0d 100644 --- a/messages/en/settings.json +++ b/messages/en/settings.json @@ -55,9 +55,6 @@ "settings_profile_name_hint": "Displayed on inspection reports.", "settings_profile_phone_label": "Phone", "settings_profile_phone_placeholder": "(555) 123-4567", - "settings_profile_license_label": "License #", - "settings_profile_license_placeholder": "HI-12345", - "settings_profile_license_hint": "State inspector license number.", "settings_profile_timezone_label": "Your timezone", "settings_profile_timezone_hint": "Overrides how times appear for you only. Reports and calendar events always use the company timezone.", "settings_profile_timezone_inherit_option": "Use company timezone", @@ -77,14 +74,14 @@ "settings_notifications_desc": "These are the messages addressed to you personally. Alerts your whole company receives are set under Automations.", "settings_notifications_error": "Couldn't save that. Please try again.", "settings_notifications_unavailable": "Your notification settings couldn't be loaded. Reload the page to try again.", - "settings_profile_credentials_heading": "Credentials & badges", - "settings_profile_credentials_subtitle": "Association memberships and licenses shown on your reports, emails, and booking page. Upload a badge image, or add a text credential.", - "settings_profile_credentials_empty": "No credentials yet. Add one to show it on your reports.", + "settings_profile_credentials_heading": "Licenses & affiliations", + "settings_profile_credentials_subtitle": "Your license and any association memberships. These appear on your reports, your email signature, and your booking page. Upload a badge image, or add a text-only entry.", + "settings_profile_credentials_empty": "Nothing here yet. Add your license or an association membership to show it on your reports.", "settings_profile_credentials_details_summary": "Details (label · member #)", - "settings_profile_credentials_label_placeholder": "e.g. InterNACHI Certified Professional Inspector", - "settings_profile_credentials_member_placeholder": "Member # (optional)", + "settings_profile_credentials_label_placeholder": "e.g. Licensed home inspector, or InterNACHI CPI", + "settings_profile_credentials_member_placeholder": "License or member # (optional)", "settings_profile_credentials_remove": "Remove", - "settings_profile_credentials_add": "+ Add credential", + "settings_profile_credentials_add": "+ Add license or affiliation", "settings_profile_signature_heading": "Email signature", "settings_profile_signature_subtitle": "The business-card footer added to emails you send. Built from the fields above — save your profile to refresh the preview.", "settings_profile_signature_toggle": "Add to my emails", diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 0528bd6d0..74e5901a8 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -2,8 +2,8 @@ "app/routes/inspection-edit.tsx": 2489, "app/routes/inspector-portal.tsx": 1180, "server/services/inspection/inspection-core.service.ts": 1117, - "server/services/booking.service.ts": 967, - "server/services/inspection/inspection-report.service.ts": 949, + "server/services/booking.service.ts": 966, + "server/services/inspection/inspection-report.service.ts": 948, "server/durable-objects/inspection-doc.ts": 912, "server/lib/collab/results-doc.ts": 874, "app/routes/inspections.tsx": 867, @@ -23,7 +23,7 @@ "app/components/editor/ItemEditor.tsx": 637, "app/components/inspection-edit/CompliancePanel.tsx": 623, "app/components/portal/sections/RepairBuilderSection.tsx": 616, - "server/services/inspection/inspection-publish.service.ts": 611, + "server/services/inspection/inspection-publish.service.ts": 616, "app/hooks/useInspection.ts": 600, "server/services/marketplace.service.ts": 587, "server/services/concierge.service.ts": 586, @@ -32,8 +32,8 @@ "server/api/inspections/core.ts": 561, "app/lib/collab/results-binding.ts": 560, "server/api/calendar.ts": 547, - "app/routes/settings-profile.tsx": 545, "server/api/public-report.ts": 541, + "app/routes/settings-profile.tsx": 532, "server/services/inspection/inspection-photo.service.ts": 531, "app/components/NewInspectionWizard.tsx": 530, "server/api/inspections/media-studio.ts": 530, diff --git a/server/api/auth/profile.ts b/server/api/auth/profile.ts index b7fa98548..cb98f1c0f 100644 --- a/server/api/auth/profile.ts +++ b/server/api/auth/profile.ts @@ -144,7 +144,6 @@ const updateProfileRoute = createRoute(withMcpMetadata({ schema: z.object({ name: z.string().max(100).optional().describe('Display name shown on dashboards, reports, and booking pages.'), phone: z.string().max(30).optional().describe('Contact phone number; included on reports if set.'), - licenseNumber: z.string().max(50).optional().describe('Inspector license number; printed on reports as a credential.'), }).describe('TODO describe schema field for the OpenInspection MCP integration') } } @@ -228,7 +227,6 @@ const profileRoutes = createApiRouter() email: users.email, name: users.name, phone: users.phone, - licenseNumber: users.licenseNumber, onboardingState: users.onboardingState, totpEnabled: users.totpEnabled, totpRecoveryCodes: users.totpRecoveryCodes, @@ -248,7 +246,6 @@ const profileRoutes = createApiRouter() email: row?.email, name: row?.name || null, phone: row?.phone || null, - licenseNumber: row?.licenseNumber || null, onboardingState: row?.onboardingState ?? null, tenantId: c.get('tenantId'), role: c.get('userRole'), @@ -270,7 +267,6 @@ const profileRoutes = createApiRouter() const updates: Record = {}; if (body.name !== undefined) updates.name = body.name || null; if (body.phone !== undefined) updates.phone = body.phone || null; - if (body.licenseNumber !== undefined) updates.licenseNumber = body.licenseNumber || null; if (Object.keys(updates).length > 0) { const db = getDrizzle(c); diff --git a/server/api/profile.ts b/server/api/profile.ts index 17cb9103b..aa3dc2415 100644 --- a/server/api/profile.ts +++ b/server/api/profile.ts @@ -36,7 +36,6 @@ const getProfileRoute = createRoute(withMcpMetadata({ name: z.string().nullable(), email: z.string(), phone: z.string().nullable(), - licenseNumber: z.string().nullable(), slug: z.string().nullable(), photoUrl: z.string().nullable(), signatureEnabled: z.boolean(), @@ -59,7 +58,6 @@ const getProfileRoute = createRoute(withMcpMetadata({ export const PatchProfileSchema = z.object({ name: z.string().max(100).optional().describe('Display name shown on reports and the booking page'), phone: z.string().max(30).optional().describe('Contact phone number for the inspector profile'), - licenseNumber: z.string().max(50).optional().describe('Professional inspector license or certification number'), signatureEnabled: z.boolean().optional().describe('Whether the inspector business-card footer is added to outbound emails'), timezone: z.string().refine((v) => v === '' || isValidTimeZone(v), 'Invalid timezone').optional().describe('Per-user display timezone (IANA). Empty string clears the override (inherit tenant).'), locale: z.string().refine((v) => v === '' || isValidLocale(v), 'Invalid locale').optional().describe('Per-user display locale (BCP-47). Empty string clears the override (inherit tenant).'), @@ -71,7 +69,7 @@ const patchProfileRoute = createRoute(withMcpMetadata({ operationId: 'patchMyProfile', tags: ['profile'], summary: 'Update current user profile', - description: 'Partially updates the authenticated user\'s profile (name, phone, licenseNumber). DB-12: slug is frozen for inspectors — the field is silently stripped if sent. Agent slugs use POST /api/agent/profile.', + description: 'Partially updates the authenticated user\'s profile (name, phone). DB-12: slug is frozen for inspectors — the field is silently stripped if sent. Agent slugs use POST /api/agent/profile.', request: { body: { content: { @@ -132,7 +130,6 @@ const profileRoutes = createApiRouter() name: users.name, email: users.email, phone: users.phone, - licenseNumber: users.licenseNumber, slug: users.slug, photoUrl: users.photoUrl, signatureEnabled: users.signatureEnabled, @@ -156,7 +153,7 @@ const profileRoutes = createApiRouter() const signaturePreviewHtml = (row.name ?? '').trim() ? inspectorSignature({ name: row.name, email: row.email, phone: row.phone, - licenseNumber: row.licenseNumber, tenantSlug, credentials, + tenantSlug, credentials, }, host).html : ''; @@ -175,7 +172,6 @@ const profileRoutes = createApiRouter() if (body.name !== undefined) updates.name = body.name; if (body.phone !== undefined) updates.phone = body.phone; - if (body.licenseNumber !== undefined) updates.licenseNumber = body.licenseNumber; if (body.signatureEnabled !== undefined) updates.signatureEnabled = body.signatureEnabled; // Per-user timezone override: empty string clears it (NULL = inherit tenant). if (body.timezone !== undefined) updates.timezone = body.timezone === '' ? null : body.timezone; diff --git a/server/lib/db/schema/tenant/user.ts b/server/lib/db/schema/tenant/user.ts index 37a4d099b..5ee534123 100644 --- a/server/lib/db/schema/tenant/user.ts +++ b/server/lib/db/schema/tenant/user.ts @@ -18,6 +18,13 @@ export const users = sqliteTable('users', { passwordHash: text('password_hash').notNull(), name: text('name'), phone: text('phone'), + // -- DEAD (2026-08-01, retired in favour of inspector_credentials). + // The licence is a credential row now: the backfill seeded one per licensed + // user at sort_order -1, and every renderer reads + // `CredentialService.primaryLicenseNumber`. No live path reads or writes + // this column. Frozen rather than dropped per the Schema Rules — D1 cannot + // drop a column on an FK-referenced table, and the name must never be + // reused. licenseNumber: text('license_number'), // Inspector avatar shown on the public company booking page (/book/:tenant). photoUrl: text('photo_url'), diff --git a/server/lib/inspector-signature.ts b/server/lib/inspector-signature.ts index 8b4d09452..a9c5adf20 100644 --- a/server/lib/inspector-signature.ts +++ b/server/lib/inspector-signature.ts @@ -20,6 +20,12 @@ export interface SignatureUser { name?: string | null; email?: string | null; phone?: string | null; + /** + * @deprecated FROZEN. `users.license_number` is retired — the licence is a + * credential row now and arrives in `credentials`. The field stays on the + * type only so a caller that still passes it does not fail to compile; it + * is not rendered. Remove once no caller sets it. + */ licenseNumber?: string | null; /** * DB-12 / IA-26 — inspector booking slugs are retired. This field is @@ -64,7 +70,6 @@ const phoneTel = (raw: string | null | undefined): string | null => { export function inspectorSignature(user: SignatureUser, host: string): SignatureOutput { const name = user.name ? escapeHtml(user.name) : null; - const license = user.licenseNumber ? escapeHtml(user.licenseNumber) : null; const email = user.email ? escapeHtml(user.email) : null; const phoneRaw = user.phone ? escapeHtml(user.phone) : null; const phoneE164 = phoneTel(user.phone ?? null); @@ -76,7 +81,10 @@ export function inspectorSignature(user: SignatureUser, host: string): Signature const htmlLines: string[] = []; if (name) htmlLines.push(`— ${name}`); - if (license) htmlLines.push(`Licensed home inspector · ${license}`); + // The hard-coded "Licensed home inspector · " line is gone: the licence + // is a credential row and renders below with the rest of them, under the + // label the backfill gave it. Two sources for one line is how a recipient + // ends up reading the licence twice. // Credential badges (Spec B): images in HTML, all credentials also as text. const creds = (user.credentials ?? []).filter((c) => c.imageUrl || (c.label ?? '').trim()); if (creds.length) { @@ -100,7 +108,6 @@ export function inspectorSignature(user: SignatureUser, host: string): Signature const textLines: string[] = ['--']; if (user.name) textLines.push(`— ${user.name}`); - if (user.licenseNumber) textLines.push(`Licensed home inspector · ${user.licenseNumber}`); const credTextAll = (user.credentials ?? []) .map((c) => (c.memberNumber ? `${c.label} #${c.memberNumber}` : c.label)) .filter((t) => (t ?? '').trim()) diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json index 7c2f8120a..dc2acedcc 100644 --- a/server/lib/mcp/openapi-snapshot.json +++ b/server/lib/mcp/openapi-snapshot.json @@ -14440,11 +14440,6 @@ "maxLength": 30, "description": "Contact phone number for the inspector profile" }, - "licenseNumber": { - "type": "string", - "maxLength": 50, - "description": "Professional inspector license or certification number" - }, "signatureEnabled": { "type": "boolean", "description": "Whether the inspector business-card footer is added to outbound emails" @@ -14461,7 +14456,7 @@ } }, "summary": "Update current user profile", - "description": "Partially updates the authenticated user's profile (name, phone, licenseNumber). DB-12: slug is frozen for inspectors — the field is silently stripped if sent. Agent slugs use POST /api/agent/profile." + "description": "Partially updates the authenticated user's profile (name, phone). DB-12: slug is frozen for inspectors — the field is silently stripped if sent. Agent slugs use POST /api/agent/profile." }, { "operationId": "patchTenantAttentionThreshold", @@ -20248,11 +20243,6 @@ "type": "string", "maxLength": 30, "description": "Contact phone number; included on reports if set." - }, - "licenseNumber": { - "type": "string", - "maxLength": 50, - "description": "Inspector license number; printed on reports as a credential." } }, "description": "TODO describe schema field for the OpenInspection MCP integration" diff --git a/server/lib/pdf.ts b/server/lib/pdf.ts index e893d4bdf..3accfce04 100644 --- a/server/lib/pdf.ts +++ b/server/lib/pdf.ts @@ -35,7 +35,10 @@ import { logger } from './logger'; * so previously content-hashed PDFs (rendered under the old gated path) * re-render under the new mechanism. */ -export const RENDER_VERSION = 'r10'; +// r11 — the footer licence now comes from the inspector's credential row +// rather than the retired `users.license_number`. Bumped so cached PDFs are +// re-rendered instead of serving a footer built from a column nothing reads. +export const RENDER_VERSION = 'r11'; /** * Backoff before the single pass-2 retry when Browser Rendering rate-limits the diff --git a/server/lib/sign-effects.ts b/server/lib/sign-effects.ts index 948e19164..5015e1f9d 100644 --- a/server/lib/sign-effects.ts +++ b/server/lib/sign-effects.ts @@ -162,7 +162,7 @@ async function buildSignedConfirmation( inspectorEmail: string | null; sigInspector: { name: string | null; email: string | null; phone: string | null; - licenseNumber: string | null; slug: string | null; + slug: string | null; } | undefined; }> { const baseUrl = (c.env.APP_BASE_URL || '').replace(/\/$/, '') || (() => { @@ -192,7 +192,6 @@ async function buildSignedConfirmation( name: inspectorRow.name ?? null, email: inspectorRow.email ?? null, phone: inspectorRow.phone ?? null, - licenseNumber: inspectorRow.licenseNumber ?? null, slug: inspectorRow.slug ?? null, } : undefined; diff --git a/server/lib/signature-helpers.ts b/server/lib/signature-helpers.ts index da6991079..5695d9fa3 100644 --- a/server/lib/signature-helpers.ts +++ b/server/lib/signature-helpers.ts @@ -21,7 +21,6 @@ export type SenderSignature = { name: string | null; email: string | null; phone: string | null; - licenseNumber: string | null; signatureEnabled: boolean | null; /** * Spec B — the sender's active credentials. This field is why the badges @@ -84,7 +83,6 @@ export async function resolveSignatureInspector( name: users.name, email: users.email, phone: users.phone, - licenseNumber: users.licenseNumber, slug: users.slug, signatureEnabled: users.signatureEnabled, }).from(users).where(and(eq(users.id, inspectorId), eq(users.tenantId, tenantId))).get(); @@ -113,7 +111,6 @@ export async function lookupSenderSignature(c: Context, tenantId: st name: users.name, email: users.email, phone: users.phone, - licenseNumber: users.licenseNumber, signatureEnabled: users.signatureEnabled, }).from(users) .where(and(eq(users.id, senderId), eq(users.tenantId, tenantId))) diff --git a/server/services/booking.service.ts b/server/services/booking.service.ts index 0b9d482e2..ef92f2c94 100644 --- a/server/services/booking.service.ts +++ b/server/services/booking.service.ts @@ -815,7 +815,6 @@ export class BookingService { name: inspector.name ?? null, email: inspector.email ?? null, phone: inspector.phone ?? null, - licenseNumber: inspector.licenseNumber ?? null, slug: inspector.slug ?? null, credentials: bookingCreds, } : undefined; diff --git a/server/services/credential.service.ts b/server/services/credential.service.ts index 34ada612b..dd4e22c29 100644 --- a/server/services/credential.service.ts +++ b/server/services/credential.service.ts @@ -64,6 +64,25 @@ export class CredentialService { })); } + /** + * The inspector's LICENCE NUMBER, for the surfaces that render one string. + * + * The PDF footer prints `· Lic. ` and the report signature block carries a + * single licence — neither can show a list. `users.license_number` used to + * answer this; it is frozen, and the licence now lives as a credential row + * seeded at `sort_order = -1` by the backfill, which is exactly why that sort + * order was chosen rather than 0. So "first active credential carrying a + * member number, in the inspector's own order" IS the licence. + * + * Null when they have none, and the callers omit the line rather than + * printing an empty one. + */ + async primaryLicenseNumber(tenantId: string, userId: string): Promise { + const rows = await this.listByUser(tenantId, userId); + const licensed = rows.find((cr) => cr.active && (cr.memberNumber ?? '').trim()); + return licensed?.memberNumber?.trim() || null; + } + async create( tenantId: string, userId: string, diff --git a/server/services/inspection/inspection-publish.service.ts b/server/services/inspection/inspection-publish.service.ts index 91ac6adda..cd5ed6b0c 100644 --- a/server/services/inspection/inspection-publish.service.ts +++ b/server/services/inspection/inspection-publish.service.ts @@ -18,6 +18,7 @@ import { } from './shared'; import { communicationCounts } from '../../lib/communication-counts'; import { InspectionSubService } from './base'; +import { CredentialService } from '../credential.service'; import type { InspectionService } from '../inspection.service'; /** Normalise a possibly-JSON-encoded D1 column: parse when it's a string, @@ -135,13 +136,17 @@ export class InspectionPublishService extends InspectionSubService { const branding = await db.select({ companyName: tenantConfigs.companyName, primaryColor: tenantConfigs.primaryColor, defaultLocale: tenantConfigs.defaultLocale }) .from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); - let inspector: { name: string | null; email: string | null; phone: string | null; licenseNumber: string | null } | undefined; + let inspector: { name: string | null; email: string | null; phone: string | null } | undefined; + let licenseNumber: string | null = null; if (insp.inspectorId) { inspector = await db.select({ - name: users.name, email: users.email, phone: users.phone, licenseNumber: users.licenseNumber, + name: users.name, email: users.email, phone: users.phone, }).from(users) .where(and(eq(users.id, insp.inspectorId), eq(users.tenantId, tenantId))) .get(); + // `users.license_number` is frozen; the licence is a credential row. + licenseNumber = await new CredentialService(this.db) + .primaryLicenseNumber(tenantId, insp.inspectorId); } // Surface the invoice amount whenever payment is part of the gate (the @@ -207,7 +212,7 @@ export class InspectionPublishService extends InspectionSubService { inspectorName: inspector?.name ?? null, inspectorEmail: inspector?.email ?? null, inspectorPhone: inspector?.phone ?? null, - inspectorLicense: inspector?.licenseNumber ?? null, + inspectorLicense: licenseNumber, scheduledDate: insp.date ?? null, amountCents, // Snapshot currency from the invoice (Phase B); fall back to USD only diff --git a/server/services/inspection/inspection-report.service.ts b/server/services/inspection/inspection-report.service.ts index 7883dd6f9..324658b48 100644 --- a/server/services/inspection/inspection-report.service.ts +++ b/server/services/inspection/inspection-report.service.ts @@ -479,10 +479,13 @@ export class InspectionReportService extends InspectionSubService { let inspectorName: string | null = null; let inspectorLicense: string | null = null; if (inspection.inspectorId) { - const inspector = await db.select({ name: users.name, email: users.email, licenseNumber: users.licenseNumber }) + const inspector = await db.select({ name: users.name, email: users.email }) .from(users).where(eq(users.id, inspection.inspectorId)).get(); inspectorName = inspector?.name || (inspector?.email?.split('@')[0] ?? null); - inspectorLicense = inspector?.licenseNumber ?? null; + // `users.license_number` is frozen. The licence is a credential row + // now, seeded ahead of the voluntary badges by the backfill. + inspectorLicense = await new CredentialService(this.db) + .primaryLicenseNumber(tenantId, inspection.inspectorId); } // Inspector Credentials & Association Badges (Spec B). @@ -900,7 +903,7 @@ export class InspectionReportService extends InspectionSubService { * showLicense + companyAddress) from tenant_configs (default ON). * - address: the inspection's property address (footer fallback when the * tenant has no companyAddress configured). - * - license: the assigned inspector's users.licenseNumber (or null when no + * - license: the assigned inspector's licence credential row (or null when no * inspector is assigned / the user row carries no license). * * All reads are filtered by tenantId so a footer can never leak a foreign @@ -929,15 +932,11 @@ export class InspectionReportService extends InspectionSubService { .where(eq(tenantConfigs.tenantId, tenantId)) .get(); - let license: string | null = null; - if (insp?.inspectorId) { - const owner = await db - .select({ licenseNumber: users.licenseNumber }) - .from(users) - .where(and(eq(users.id, insp.inspectorId), eq(users.tenantId, tenantId))) - .get(); - license = owner?.licenseNumber ?? null; - } + // PDF footer licence — same source as the report payload's, so the two + // can never print different numbers for the same inspector. + const license: string | null = insp?.inspectorId + ? await new CredentialService(this.db).primaryLicenseNumber(tenantId, insp.inspectorId) + : null; return { settings: resolvePdfSettings(cfg), diff --git a/tests/unit/agreements/inspector-signature.spec.ts b/tests/unit/agreements/inspector-signature.spec.ts index 7df0c154b..38fa13d03 100644 --- a/tests/unit/agreements/inspector-signature.spec.ts +++ b/tests/unit/agreements/inspector-signature.spec.ts @@ -5,9 +5,13 @@ const FULL_USER = { name: 'Mike Reynolds', email: 'mike@acme.test', phone: '(303) 555-0142', - licenseNumber: 'TX-INSP-9001', slug: 'mike', // retained for API stability (DB-12); no longer used for URL tenantSlug: 'acme', + // The licence is a CREDENTIAL now, sorted ahead of voluntary badges by the + // backfill. `users.license_number` is frozen and no longer rendered. + credentials: [ + { label: 'Licensed home inspector', memberNumber: 'TX-INSP-9001', imageUrl: null }, + ], } as const; const HOST = 'app.inspectorhub.io'; @@ -72,12 +76,25 @@ describe('inspectorSignature — Sprint B-4 / DB-12', () => { expect(sig.text).not.toContain('Book again'); }); - it('omits license line when licenseNumber is null', () => { - const sig = inspectorSignature({ ...FULL_USER, licenseNumber: null }, HOST); + it('omits the licence line when there is no licence credential', () => { + const sig = inspectorSignature({ ...FULL_USER, credentials: [] }, HOST); expect(sig.html).not.toContain('Licensed home inspector'); expect(sig.text).not.toContain('Licensed home inspector'); }); + it('IGNORES the retired users.license_number entirely', () => { + // The stronger claim, and the one that matters during the transition: a + // caller that still passes the frozen column must not put a second + // licence line on the signature beside the credential one. Two sources + // for one line is how a recipient reads the licence twice. + const sig = inspectorSignature( + { ...FULL_USER, credentials: [], licenseNumber: 'TX-STALE-1' }, + HOST, + ); + expect(sig.html).not.toContain('TX-STALE-1'); + expect(sig.text).not.toContain('TX-STALE-1'); + }); + it('omits everything when user has no fields at all', () => { const sig = inspectorSignature({}, HOST); // Only the wrapper div / "--" leader remain. @@ -89,10 +106,10 @@ describe('inspectorSignature — Sprint B-4 / DB-12', () => { const sig = inspectorSignature(FULL_USER, HOST); expect(sig).toMatchInlineSnapshot(` { - "html": "
— Mike Reynolds
Licensed home inspector · TX-INSP-9001
📞 (303) 555-0142 ✉️ mike@acme.test
Book again: https://app.inspectorhub.io/book/acme
", + "html": "
— Mike Reynolds
Licensed home inspector #TX-INSP-9001
📞 (303) 555-0142 ✉️ mike@acme.test
Book again: https://app.inspectorhub.io/book/acme
", "text": "-- — Mike Reynolds - Licensed home inspector · TX-INSP-9001 + Licensed home inspector #TX-INSP-9001 (303) 555-0142 · mike@acme.test Book again: https://app.inspectorhub.io/book/acme", } diff --git a/tests/unit/credentials/service.spec.ts b/tests/unit/credentials/service.spec.ts index c16c982b5..c1a969a5d 100644 --- a/tests/unit/credentials/service.spec.ts +++ b/tests/unit/credentials/service.spec.ts @@ -116,3 +116,57 @@ describe('CredentialService.listRenderable', () => { expect((await svc.listRenderable(T, U)).map((c) => c.label)).toEqual(['Mine']); }); }); + +/** + * `primaryLicenseNumber` — the one string the surfaces that cannot show a list + * are allowed to print. + * + * The PDF footer prints `· Lic. ` and the report signature block carries a + * single licence. `users.license_number` used to answer this and is now frozen, + * so the answer comes from the credential the backfill seeded at + * `sort_order = -1` — which is precisely why that sort order was chosen instead + * of 0, and why this rule can be stated as "first active credential carrying a + * member number, in the inspector own order". + */ +describe('CredentialService.primaryLicenseNumber', () => { + let svc: CredentialService; + let testDb: BetterSQLite3Database; + + beforeEach(async () => { + const f = createTestDb(); testDb = f.db; await setupSchema(f.sqlite); + (mockDrizzle as unknown as ReturnType).mockReturnValue(testDb); + svc = new CredentialService({} as D1Database); + }); + + it('returns the licence, not a voluntary badge that happens to sort first', async () => { + await svc.create(T, U, { label: 'InterNACHI CPI', memberNumber: 'N-1', sortOrder: 0 }); + await svc.create(T, U, { label: 'Licensed home inspector', memberNumber: 'TX-9001', sortOrder: -1 }); + expect(await svc.primaryLicenseNumber(T, U)).toBe('TX-9001'); + }); + + it('skips credentials with no member number — a badge image is not a licence', async () => { + await svc.create(T, U, { label: 'Association logo', sortOrder: -2 }); + await svc.create(T, U, { label: 'Licensed home inspector', memberNumber: 'TX-9001', sortOrder: -1 }); + expect(await svc.primaryLicenseNumber(T, U)).toBe('TX-9001'); + }); + + it('skips an inactive licence', async () => { + const a = await svc.create(T, U, { label: 'Old licence', memberNumber: 'TX-OLD', sortOrder: -1 }); + await testDb.update(schema.inspectorCredentials).set({ active: false }) + .where(eq(schema.inspectorCredentials.id, a.id)); + await svc.create(T, U, { label: 'Current licence', memberNumber: 'TX-NEW', sortOrder: 0 }); + expect(await svc.primaryLicenseNumber(T, U)).toBe('TX-NEW'); + }); + + it('returns null when there is nothing to print', async () => { + // The callers omit the line rather than printing an empty one. + expect(await svc.primaryLicenseNumber(T, U)).toBeNull(); + await svc.create(T, U, { label: 'Badge only' }); + expect(await svc.primaryLicenseNumber(T, U)).toBeNull(); + }); + + it('never reads another user licence', async () => { + await svc.create(T, U2, { label: 'Licensed home inspector', memberNumber: 'NOT-MINE', sortOrder: -1 }); + expect(await svc.primaryLicenseNumber(T, U)).toBeNull(); + }); +}); diff --git a/tests/unit/reports/report-signature-payload.spec.ts b/tests/unit/reports/report-signature-payload.spec.ts index 6ab657fb6..beb97c9c2 100644 --- a/tests/unit/reports/report-signature-payload.spec.ts +++ b/tests/unit/reports/report-signature-payload.spec.ts @@ -31,10 +31,19 @@ async function seedBase(testDb: BetterSQLite3Database) { email: 'inspector@example.com', passwordHash: 'x', name: 'Alice Inspector', - licenseNumber: 'LIC-9999', role: 'inspector', createdAt: new Date(), }); + // The licence is a credential row now — `users.license_number` is frozen. + // Seeded at sort_order -1, which is where the backfill puts it and why the + // "first credential carrying a member number" rule finds the licence rather + // than a voluntary badge. + await testDb.insert(schema.inspectorCredentials).values({ + id: 'cred-license', tenantId: TENANT, userId: INSPECTOR, + label: 'Licensed home inspector', memberNumber: 'LIC-9999', + imageR2Key: null, sortOrder: -1, active: true, + createdAt: new Date(), updatedAt: new Date(), + }); await testDb.insert(schema.inspections).values({ id: INSPECTION, tenantId: TENANT, From fdccf68caf6f667a50d860ab4bcc33bb9aea0444 Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 1 Aug 2026 06:18:05 +0800 Subject: [PATCH 32/48] chore: drop exports nothing imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm run lint` is the only rung that runs knip, so these accumulated across the branch without any commit gate seeing them. Six dead exports, all the same shape: a type used inside its own module and exported as though something outside wanted it. `loginPathFor` is mine, from the agent-logout fix. Its two callers are both in `session.server.ts` and the specs reach it through `requireToken`, which is the surface that actually exists — so the export was aspiration, not API. `ChannelId` was a pure pass-through re-export from `NotificationSettings`, and removing it orphaned the import that fed it — which eslint caught at the commit gate, since an unused import is an error there while the dead export was invisible to it. The other four (`ChannelState` x2, `SmsConsentState`, `ScreenRow`) are internal to their modules. Nothing changes at runtime; the point is that the next real dead export is visible instead of being the seventh line of a list nobody reads. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HcJgdRoQzrRJ8RvEcpUDR5 --- app/components/notifications/NotificationPreferences.tsx | 2 +- app/components/notifications/NotificationSettings.tsx | 3 --- app/components/notifications/SmsConsentBlock.tsx | 2 +- app/lib/session.server.ts | 2 +- server/lib/compliance/erasure-manifest.ts | 2 +- server/lib/notifications/screen-model.ts | 4 ++-- 6 files changed, 6 insertions(+), 9 deletions(-) diff --git a/app/components/notifications/NotificationPreferences.tsx b/app/components/notifications/NotificationPreferences.tsx index d64e2b15d..588739a98 100644 --- a/app/components/notifications/NotificationPreferences.tsx +++ b/app/components/notifications/NotificationPreferences.tsx @@ -27,7 +27,7 @@ import { m } from "~/paraglide/messages"; * implementation would drift, and only one of the three would get the next fix. */ -export type ChannelState = "on" | "off"; +type ChannelState = "on" | "off"; export type ChannelId = "email" | "sms" | "in_app"; export interface AlwaysSentItem { diff --git a/app/components/notifications/NotificationSettings.tsx b/app/components/notifications/NotificationSettings.tsx index f21044578..e9dcf1425 100644 --- a/app/components/notifications/NotificationSettings.tsx +++ b/app/components/notifications/NotificationSettings.tsx @@ -2,7 +2,6 @@ import { useFetcher } from "react-router"; import { NotificationPreferences, type AlwaysSentItem, - type ChannelId, type ChoiceRow, } from "~/components/notifications/NotificationPreferences"; import { SmsConsentBlock, type SmsConsent } from "~/components/notifications/SmsConsentBlock"; @@ -120,5 +119,3 @@ export function NotificationSettings({
); } - -export type { ChannelId }; diff --git a/app/components/notifications/SmsConsentBlock.tsx b/app/components/notifications/SmsConsentBlock.tsx index ced5d1bb6..896283c1f 100644 --- a/app/components/notifications/SmsConsentBlock.tsx +++ b/app/components/notifications/SmsConsentBlock.tsx @@ -20,7 +20,7 @@ import { m } from "~/paraglide/messages"; * IS a button. */ -export type SmsConsentState = "granted" | "implied" | "revoked" | "none"; +type SmsConsentState = "granted" | "implied" | "revoked" | "none"; export interface SmsConsent { phone: string | null; diff --git a/app/lib/session.server.ts b/app/lib/session.server.ts index 6db8ab4a1..8ddb58fdc 100644 --- a/app/lib/session.server.ts +++ b/app/lib/session.server.ts @@ -182,7 +182,7 @@ function isTokenExpired(token: string, nowMs: number): boolean { * The prefix is the whole rule: `/contacts` and `/inspections/agent-notes` are * staff pages ABOUT agents and stay on the staff door. */ -export function loginPathFor(request: Request): "/login" | "/agent-login" { +function loginPathFor(request: Request): "/login" | "/agent-login" { return new URL(request.url).pathname.startsWith("/agent-") ? "/agent-login" : "/login"; } diff --git a/server/lib/compliance/erasure-manifest.ts b/server/lib/compliance/erasure-manifest.ts index ff428d71b..cad654365 100644 --- a/server/lib/compliance/erasure-manifest.ts +++ b/server/lib/compliance/erasure-manifest.ts @@ -201,6 +201,6 @@ export const ERASURE_OUT_OF_SCOPE: ErasureOutOfScopeEntry[] = [ // said at a date, which is the one thing it exists to answer. Listed rather // than left silent because the PII heuristic does not flag any column here, // and silence is not the same as a decision. - { table: 'tenant_legal_versions', column: 'body_snapshot', reason: "the company's own published policy text, not a data subject's data" }, + { table: 'tenant_legal_versions', column: 'body_snapshot', reason: 'company-authored policy text, not personal data of any data subject' }, { table: 'tenant_legal_versions', column: 'published_by_user_id', reason: 'staff author reference — not consumer-DSAR scope' }, ]; diff --git a/server/lib/notifications/screen-model.ts b/server/lib/notifications/screen-model.ts index c2c650b09..184018df3 100644 --- a/server/lib/notifications/screen-model.ts +++ b/server/lib/notifications/screen-model.ts @@ -16,9 +16,9 @@ import { NOTIFICATION_CLASSES, defaultEnabled, type Audience, type NotificationC */ /** A channel's state on a row. */ -export type ChannelState = 'on' | 'off'; +type ChannelState = 'on' | 'off'; -export interface ScreenRow { +interface ScreenRow { id: string; label: string; /** From 202061eee0c7d45443a15142a3d9d37eaeea081f Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 1 Aug 2026 06:18:51 +0800 Subject: [PATCH 33/48] =?UTF-8?q?chore(deps):=20drop=20@testing-library/us?= =?UTF-8?q?er-event=20=E2=80=94=20nothing=20imports=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last of the knip findings. Zero imports across app/, tests/ and server/; the component specs drive interactions through `fireEvent`. Its own commit because package.json is one of the paths that escalates the pre-commit type-check from the api tier to the full one AND makes `vitest --changed` degrade to the whole suite — so mixing it into a code commit costs several minutes for a one-line deletion. Lockfile edited by `npm uninstall` rather than regenerated: 346 linux entries still present, so the cross-platform optional deps are intact. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HcJgdRoQzrRJ8RvEcpUDR5 --- package-lock.json | 15 --------------- package.json | 1 - 2 files changed, 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index 33eb7eca8..35ff82f90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,7 +66,6 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.1", "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.10.1", "@types/qrcode": "^1.5.6", @@ -4548,20 +4547,6 @@ } } }, - "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmmirror.com/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12", - "npm": ">=6" - }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" - } - }, "node_modules/@ts-morph/common": { "version": "0.29.0", "resolved": "https://registry.npmmirror.com/@ts-morph/common/-/common-0.29.0.tgz", diff --git a/package.json b/package.json index 2f12eafce..78778d325 100644 --- a/package.json +++ b/package.json @@ -153,7 +153,6 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.1", "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.10.1", "@types/qrcode": "^1.5.6", From 9321deb746e90b313728ba79be5031522a479add Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 1 Aug 2026 06:22:40 +0800 Subject: [PATCH 34/48] fix(schema): notification_preferences.enabled -> is_enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The naming rule says boolean columns carry an `is_`/`has_` prefix, and `lint:naming` enforces it — but that gate runs only in the full `npm run lint`, never at the commit gate. So the column shipped bare and stayed that way for a dozen commits on this branch, which is the whole argument for running the full suite before pushing rather than trusting the hook. RENAME, not drop-and-add: D1 rebuilds a table to drop a column, and a rebuild is the operation this branch has already been bitten by once. `ALTER TABLE ... RENAME COLUMN` touches nothing else. The drizzle PROPERTY stays `enabled`, so no call site, no Zod field and no API response moves — the change is entirely at the DB boundary, where the rule applies. `db:generate` could not produce this (its rename-vs-drop prompt is interactive), so the SQL, the meta snapshot and the journal entry are hand-written; `db:check` confirms migrations and schema still agree at 86 tables. Full lint now passes every gate: 0 eslint errors, and DS, SVG, erasure, migration-refs, tenant-scope, status-literals, capability-decl, provider-helpers, notification-dispatch, deadcode, timestamps, tz, i18n, i18n-catalog and naming all green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HcJgdRoQzrRJ8RvEcpUDR5 --- .../0023_notification_pref_enabled_rename.sql | 10 + migrations/meta/0023_snapshot.json | 10126 ++++++++++++++++ migrations/meta/_journal.json | 7 + .../lib/db/schema/notification-preferences.ts | 5 +- 4 files changed, 10147 insertions(+), 1 deletion(-) create mode 100644 migrations/0023_notification_pref_enabled_rename.sql create mode 100644 migrations/meta/0023_snapshot.json diff --git a/migrations/0023_notification_pref_enabled_rename.sql b/migrations/0023_notification_pref_enabled_rename.sql new file mode 100644 index 000000000..c3ff08281 --- /dev/null +++ b/migrations/0023_notification_pref_enabled_rename.sql @@ -0,0 +1,10 @@ +-- Naming rule: boolean columns are `is_`/`has_` prefixed (`lint:naming`). +-- +-- `notification_preferences` shipped on this branch with a bare `enabled`, and +-- the gate that would have caught it runs only in the full lint, never at the +-- commit gate — so it went unnoticed for a dozen commits. +-- +-- RENAME, not drop-and-add: D1 rebuilds a table to drop a column, and a rebuild +-- is the operation this repository has already been bitten by. The drizzle +-- PROPERTY stays `enabled`, so no call site and no API field moves. +ALTER TABLE `notification_preferences` RENAME COLUMN `enabled` TO `is_enabled`; diff --git a/migrations/meta/0023_snapshot.json b/migrations/meta/0023_snapshot.json new file mode 100644 index 000000000..329316d13 --- /dev/null +++ b/migrations/meta/0023_snapshot.json @@ -0,0 +1,10126 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0023-notification-pref-enabled-rename", + "prevId": "595f38f0-07a0-45bf-9483-8ff1bcd9d296", + "tables": { + "agreement_requests": { + "name": "agreement_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agreement_id": { + "name": "agreement_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "signature_base64": { + "name": "signature_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_at": { + "name": "signed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "viewed_at": { + "name": "viewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_at": { + "name": "sent_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_signature_base64": { + "name": "inspector_signature_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_signed_at": { + "name": "inspector_signed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_user_id": { + "name": "inspector_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_snapshot": { + "name": "content_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completion_policy": { + "name": "completion_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'all'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purged_at": { + "name": "purged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agreement_requests_token_unique": { + "name": "agreement_requests_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "idx_agreement_requests_verify_token": { + "name": "idx_agreement_requests_verify_token", + "columns": [ + "verification_token" + ], + "isUnique": true + }, + "idx_agreement_requests_tenant": { + "name": "idx_agreement_requests_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_agreement_requests_inspection": { + "name": "idx_agreement_requests_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "idx_agreement_requests_token_hash": { + "name": "idx_agreement_requests_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "agreement_requests_tenant_id_tenants_id_fk": { + "name": "agreement_requests_tenant_id_tenants_id_fk", + "tableFrom": "agreement_requests", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agreement_requests_inspection_id_inspections_id_fk": { + "name": "agreement_requests_inspection_id_inspections_id_fk", + "tableFrom": "agreement_requests", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agreement_requests_agreement_id_agreements_id_fk": { + "name": "agreement_requests_agreement_id_agreements_id_fk", + "tableFrom": "agreement_requests", + "tableTo": "agreements", + "columnsFrom": [ + "agreement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agreement_requests_inspector_user_id_users_id_fk": { + "name": "agreement_requests_inspector_user_id_users_id_fk", + "tableFrom": "agreement_requests", + "tableTo": "users", + "columnsFrom": [ + "inspector_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agreement_signers": { + "name": "agreement_signers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'client'" + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_enc": { + "name": "token_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "signature_base64": { + "name": "signature_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_at": { + "name": "signed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "viewed_at": { + "name": "viewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "on_behalf_of": { + "name": "on_behalf_of", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "on_behalf_disclaimer": { + "name": "on_behalf_disclaimer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_reminded_at": { + "name": "last_reminded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_agreement_signers_tenant_request": { + "name": "idx_agreement_signers_tenant_request", + "columns": [ + "tenant_id", + "request_id" + ], + "isUnique": false + }, + "idx_agreement_signers_request_email": { + "name": "idx_agreement_signers_request_email", + "columns": [ + "request_id", + "email" + ], + "isUnique": true + }, + "idx_agreement_signers_token_hash": { + "name": "idx_agreement_signers_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agreements": { + "name": "agreements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_agreements_tenant": { + "name": "idx_agreements_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "agreements_tenant_id_tenants_id_fk": { + "name": "agreements_tenant_id_tenants_id_fk", + "tableFrom": "agreements", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automation_logs": { + "name": "automation_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_role_key": { + "name": "recipient_role_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'email'" + }, + "send_at": { + "name": "send_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_contact_id": { + "name": "recipient_contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notice_id": { + "name": "notice_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_logs_pending": { + "name": "idx_automation_logs_pending", + "columns": [ + "tenant_id", + "status", + "send_at" + ], + "isUnique": false + }, + "idx_automation_logs_insp": { + "name": "idx_automation_logs_insp", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "uq_automation_logs_event": { + "name": "uq_automation_logs_event", + "columns": [ + "automation_id", + "inspection_id", + "event_id", + "channel", + "recipient" + ], + "isUnique": true, + "where": "event_id IS NOT NULL" + } + }, + "foreignKeys": { + "automation_logs_tenant_id_tenants_id_fk": { + "name": "automation_logs_tenant_id_tenants_id_fk", + "tableFrom": "automation_logs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automations": { + "name": "automations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_kind": { + "name": "recipient_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_role_profile_id": { + "name": "recipient_role_profile_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delay_minutes": { + "name": "delay_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "subject_template": { + "name": "subject_template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body_template": { + "name": "body_template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_template_id": { + "name": "email_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "conditions": { + "name": "conditions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channels": { + "name": "channels", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"email\"]'" + }, + "sms_body": { + "name": "sms_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sms_template_id": { + "name": "sms_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "in_app_template_id": { + "name": "in_app_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automations_tenant": { + "name": "idx_automations_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automations_tenant_id_tenants_id_fk": { + "name": "automations_tenant_id_tenants_id_fk", + "tableFrom": "automations", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "availability": { + "name": "availability", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_availability_inspector": { + "name": "idx_availability_inspector", + "columns": [ + "inspector_id" + ], + "isUnique": false + }, + "idx_availability_window_unique": { + "name": "idx_availability_window_unique", + "columns": [ + "inspector_id", + "day_of_week", + "start_time" + ], + "isUnique": true + } + }, + "foreignKeys": { + "availability_tenant_id_tenants_id_fk": { + "name": "availability_tenant_id_tenants_id_fk", + "tableFrom": "availability", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "availability_inspector_id_users_id_fk": { + "name": "availability_inspector_id_users_id_fk", + "tableFrom": "availability", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "availability_overrides": { + "name": "availability_overrides", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_available": { + "name": "is_available", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "start_time": { + "name": "start_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transparency": { + "name": "transparency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_avail_overrides_insp": { + "name": "idx_avail_overrides_insp", + "columns": [ + "inspector_id" + ], + "isUnique": false + }, + "idx_avail_overrides_block_unique": { + "name": "idx_avail_overrides_block_unique", + "columns": [ + "inspector_id", + "date" + ], + "isUnique": true, + "where": "is_available = 0 AND source IS NULL" + }, + "uq_avail_overrides_external": { + "name": "uq_avail_overrides_external", + "columns": [ + "inspector_id", + "source", + "external_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "availability_overrides_tenant_id_tenants_id_fk": { + "name": "availability_overrides_tenant_id_tenants_id_fk", + "tableFrom": "availability_overrides", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "availability_overrides_inspector_id_users_id_fk": { + "name": "availability_overrides_inspector_id_users_id_fk", + "tableFrom": "availability_overrides", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "calendar_blocks": { + "name": "calendar_blocks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_all_day": { + "name": "is_all_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_calendar_blocks_tenant_user_date": { + "name": "idx_calendar_blocks_tenant_user_date", + "columns": [ + "tenant_id", + "user_id", + "date" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "calendar_connection_read_calendars": { + "name": "calendar_connection_read_calendars", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_calendar_id": { + "name": "external_calendar_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_role": { + "name": "access_role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_conn_read_cal": { + "name": "uq_conn_read_cal", + "columns": [ + "connection_id", + "external_calendar_id" + ], + "isUnique": true + }, + "idx_conn_read_cal_tenant": { + "name": "idx_conn_read_cal_tenant", + "columns": [ + "tenant_id", + "connection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "calendar_connections": { + "name": "calendar_connections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials_enc": { + "name": "credentials_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credentials_dek_enc": { + "name": "credentials_dek_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "calendar_id": { + "name": "calendar_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connected_at": { + "name": "connected_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_calendar_connections_user_provider": { + "name": "uq_calendar_connections_user_provider", + "columns": [ + "user_id", + "provider" + ], + "isUnique": true + }, + "idx_calendar_connections_tenant_user": { + "name": "idx_calendar_connections_tenant_user", + "columns": [ + "tenant_id", + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comment_usage": { + "name": "comment_usage", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_comment_usage_user_last_used": { + "name": "idx_comment_usage_user_last_used", + "columns": [ + "tenant_id", + "user_id", + "last_used_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "comment_usage_comment_id_comments_id_fk": { + "name": "comment_usage_comment_id_comments_id_fk", + "tableFrom": "comment_usage", + "tableTo": "comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "comment_usage_tenant_id_user_id_comment_id_pk": { + "columns": [ + "tenant_id", + "user_id", + "comment_id" + ], + "name": "comment_usage_tenant_id_user_id_comment_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rating_bucket": { + "name": "rating_bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section": { + "name": "section", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_ids": { + "name": "section_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_labels": { + "name": "item_labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger_code": { + "name": "trigger_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "search_keywords": { + "name": "search_keywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_label": { + "name": "item_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repair_summary": { + "name": "repair_summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimate_min_cents": { + "name": "estimate_min_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimate_max_cents": { + "name": "estimate_max_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recommended_contractor_type_id": { + "name": "recommended_contractor_type_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_comments_tenant": { + "name": "idx_comments_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_comments_rating_bucket": { + "name": "idx_comments_rating_bucket", + "columns": [ + "tenant_id", + "rating_bucket" + ], + "isUnique": false + }, + "idx_comments_library_id": { + "name": "idx_comments_library_id", + "columns": [ + "library_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "comments_tenant_id_tenants_id_fk": { + "name": "comments_tenant_id_tenants_id_fk", + "tableFrom": "comments", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commercial_subtypes": { + "name": "commercial_subtypes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "based_on": { + "name": "based_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_disabled": { + "name": "is_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_commercial_subtypes_tenant_name": { + "name": "idx_commercial_subtypes_tenant_name", + "columns": [ + "tenant_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "commercial_subtypes_tenant_id_tenants_id_fk": { + "name": "commercial_subtypes_tenant_id_tenants_id_fk", + "tableFrom": "commercial_subtypes", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "concierge_confirm_tokens": { + "name": "concierge_confirm_tokens", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_concierge_tokens_expiry": { + "name": "idx_concierge_tokens_expiry", + "columns": [ + "expires_at" + ], + "isUnique": false + }, + "idx_concierge_confirm_token_hash": { + "name": "idx_concierge_confirm_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "concierge_confirm_tokens_inspection_id_inspections_id_fk": { + "name": "concierge_confirm_tokens_inspection_id_inspections_id_fk", + "tableFrom": "concierge_confirm_tokens", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contact_role_profiles": { + "name": "contact_role_profiles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_template_id": { + "name": "email_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sms_template_id": { + "name": "sms_template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_system": { + "name": "is_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capability_overrides": { + "name": "capability_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_crp_tenant": { + "name": "idx_crp_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_crp_tenant_key": { + "name": "uq_crp_tenant_key", + "columns": [ + "tenant_id", + "key" + ], + "isUnique": true, + "where": "is_active = 1" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contacts": { + "name": "contacts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'client'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agency": { + "name": "agency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_user_id": { + "name": "agent_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_linked_at": { + "name": "agent_linked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_revoked_at": { + "name": "agent_revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_contacts_type": { + "name": "idx_contacts_type", + "columns": [ + "tenant_id", + "type" + ], + "isUnique": false + }, + "idx_contacts_tenant": { + "name": "idx_contacts_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_contacts_tenant_email": { + "name": "uq_contacts_tenant_email", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": true, + "where": "email IS NOT NULL AND archived_at IS NULL" + }, + "uq_contacts_tenant_agent_user": { + "name": "uq_contacts_tenant_agent_user", + "columns": [ + "tenant_id", + "agent_user_id" + ], + "isUnique": true, + "where": "agent_user_id IS NOT NULL AND archived_at IS NULL" + }, + "idx_contacts_agent_user": { + "name": "idx_contacts_agent_user", + "columns": [ + "agent_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "contacts_tenant_id_tenants_id_fk": { + "name": "contacts_tenant_id_tenants_id_fk", + "tableFrom": "contacts", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contractor_types": { + "name": "contractor_types", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_contractor_types_tenant": { + "name": "idx_contractor_types_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cost_items": { + "name": "cost_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "building_id": { + "name": "building_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_index": { + "name": "instance_index", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit_id": { + "name": "unit_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finding_key": { + "name": "finding_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "system": { + "name": "system", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "component": { + "name": "component", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cost_method": { + "name": "cost_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uom": { + "name": "uom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit_cost_cents": { + "name": "unit_cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lump_sum_cents": { + "name": "lump_sum_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "eul": { + "name": "eul", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "eff_age": { + "name": "eff_age", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rul": { + "name": "rul", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suggested_remedy": { + "name": "suggested_remedy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_ref": { + "name": "section_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "photo_ref": { + "name": "photo_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_cost_items_tenant_inspection": { + "name": "idx_cost_items_tenant_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "idx_cost_items_finding_key": { + "name": "idx_cost_items_finding_key", + "columns": [ + "finding_key" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "defect_categories": { + "name": "defect_categories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#6b7280'" + }, + "is_summary_driver": { + "name": "is_summary_driver", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_seed": { + "name": "is_seed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_defect_categories_tenant": { + "name": "idx_defect_categories_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "discount_codes": { + "name": "discount_codes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uses_count": { + "name": "uses_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_discount_codes_tenant": { + "name": "idx_discount_codes_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_discount_codes_code_tenant": { + "name": "uq_discount_codes_code_tenant", + "columns": [ + "upper(code)", + "tenant_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "discount_codes_tenant_id_tenants_id_fk": { + "name": "discount_codes_tenant_id_tenants_id_fk", + "tableFrom": "discount_codes", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_review_items": { + "name": "document_review_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_key": { + "name": "document_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_requested": { + "name": "is_requested", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_received": { + "name": "is_received", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_reviewed": { + "name": "is_reviewed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_na": { + "name": "is_na", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_doc_review_inspection": { + "name": "idx_doc_review_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "uq_doc_review_item": { + "name": "uq_doc_review_item", + "columns": [ + "inspection_id", + "document_key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_suppressions": { + "name": "email_suppressions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_provider": { + "name": "source_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_event_id": { + "name": "provider_event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_email_suppressions_email": { + "name": "idx_email_suppressions_email", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "erasure_log": { + "name": "erasure_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_email": { + "name": "subject_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identity_basis": { + "name": "identity_basis", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decisions_json": { + "name": "decisions_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retained_count": { + "name": "retained_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "anonymized_count": { + "name": "anonymized_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "deleted_count": { + "name": "deleted_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_note": { + "name": "response_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_erasure_log_tenant": { + "name": "idx_erasure_log_tenant", + "columns": [ + "tenant_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "esign_audit_logs": { + "name": "esign_audit_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prev_hash": { + "name": "prev_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_fingerprint": { + "name": "key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_esign_audit_logs_request": { + "name": "idx_esign_audit_logs_request", + "columns": [ + "tenant_id", + "request_id", + "created_at" + ], + "isUnique": false + }, + "idx_esign_audit_logs_event_dedup": { + "name": "idx_esign_audit_logs_event_dedup", + "columns": [ + "tenant_id", + "request_id", + "event" + ], + "isUnique": true, + "where": "event NOT LIKE 'signer.%'" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_types": { + "name": "event_types", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_duration_min": { + "name": "default_duration_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "default_price_cents": { + "name": "default_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#6366f1'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_event_types_tenant_slug": { + "name": "uq_event_types_tenant_slug", + "columns": [ + "tenant_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_types_tenant_id_tenants_id_fk": { + "name": "event_types_tenant_id_tenants_id_fk", + "tableFrom": "event_types", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_access_tokens": { + "name": "inspection_access_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'client'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_enc": { + "name": "token_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_iat_token": { + "name": "idx_iat_token", + "columns": [ + "token" + ], + "isUnique": true + }, + "idx_iat_inspection": { + "name": "idx_iat_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "idx_iat_recipient": { + "name": "idx_iat_recipient", + "columns": [ + "inspection_id", + "recipient_email" + ], + "isUnique": true + }, + "idx_iat_token_hash": { + "name": "idx_iat_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "inspection_access_tokens_tenant_id_tenants_id_fk": { + "name": "inspection_access_tokens_tenant_id_tenants_id_fk", + "tableFrom": "inspection_access_tokens", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_events": { + "name": "inspection_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type_id": { + "name": "event_type_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_min": { + "name": "duration_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'scheduled'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "results_received_at": { + "name": "results_received_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gcal_event_id": { + "name": "gcal_event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_events_scheduled": { + "name": "idx_inspection_events_scheduled", + "columns": [ + "tenant_id", + "scheduled_at" + ], + "isUnique": false + }, + "idx_inspection_events_inspection": { + "name": "idx_inspection_events_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspection_events_tenant_id_tenants_id_fk": { + "name": "inspection_events_tenant_id_tenants_id_fk", + "tableFrom": "inspection_events", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_events_inspection_id_inspections_id_fk": { + "name": "inspection_events_inspection_id_inspections_id_fk", + "tableFrom": "inspection_events", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inspection_events_event_type_id_event_types_id_fk": { + "name": "inspection_events_event_type_id_event_types_id_fk", + "tableFrom": "inspection_events", + "tableTo": "event_types", + "columnsFrom": [ + "event_type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_events_inspector_id_users_id_fk": { + "name": "inspection_events_inspector_id_users_id_fk", + "tableFrom": "inspection_events", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_inspectors": { + "name": "inspection_inspectors", + "columns": { + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'lead'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_insp_inspectors_tenant_user": { + "name": "idx_insp_inspectors_tenant_user", + "columns": [ + "tenant_id", + "user_id" + ], + "isUnique": false + }, + "idx_insp_inspectors_user": { + "name": "idx_insp_inspectors_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "inspection_inspectors_inspection_id_user_id_pk": { + "columns": [ + "inspection_id", + "user_id" + ], + "name": "inspection_inspectors_inspection_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_item_tag_links": { + "name": "inspection_item_tag_links", + "columns": { + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_tag_links_tenant": { + "name": "idx_tag_links_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_tag_links_tag": { + "name": "idx_tag_links_tag", + "columns": [ + "tag_id" + ], + "isUnique": false + }, + "idx_tag_links_inspection_item": { + "name": "idx_tag_links_inspection_item", + "columns": [ + "inspection_id", + "item_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "inspection_item_tag_links_inspection_id_item_id_tag_id_pk": { + "columns": [ + "inspection_id", + "item_id", + "tag_id" + ], + "name": "inspection_item_tag_links_inspection_id_item_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_media_pool": { + "name": "inspection_media_pool", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exif_data": { + "name": "exif_data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "annotations": { + "name": "annotations", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'photo'" + }, + "stream_uid": { + "name": "stream_uid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "poster_pct": { + "name": "poster_pct", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_sec": { + "name": "duration_sec", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'stream'" + }, + "poster_key": { + "name": "poster_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_media_pool_tenant": { + "name": "idx_media_pool_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_media_pool_inspection": { + "name": "idx_media_pool_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_messages": { + "name": "inspection_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "from_role": { + "name": "from_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachments": { + "name": "attachments", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_user_id": { + "name": "from_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_msg_inspection": { + "name": "idx_msg_inspection", + "columns": [ + "inspection_id", + "created_at" + ], + "isUnique": false + }, + "idx_msg_contact": { + "name": "idx_msg_contact", + "columns": [ + "tenant_id", + "contact_id", + "created_at" + ], + "isUnique": false + }, + "idx_msg_unread": { + "name": "idx_msg_unread", + "columns": [ + "tenant_id", + "contact_id", + "from_role" + ], + "isUnique": false, + "where": "\"inspection_messages\".\"read_at\" IS NULL" + } + }, + "foreignKeys": { + "inspection_messages_tenant_id_tenants_id_fk": { + "name": "inspection_messages_tenant_id_tenants_id_fk", + "tableFrom": "inspection_messages", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_messages_inspection_id_inspections_id_fk": { + "name": "inspection_messages_inspection_id_inspections_id_fk", + "tableFrom": "inspection_messages", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_people": { + "name": "inspection_people", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role_profile_id": { + "name": "role_profile_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_ip_inspection": { + "name": "idx_ip_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "idx_ip_tenant": { + "name": "idx_ip_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_ip_insp_contact_role": { + "name": "uq_ip_insp_contact_role", + "columns": [ + "inspection_id", + "contact_id", + "role_profile_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_requests": { + "name": "inspection_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_phone": { + "name": "client_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_address": { + "name": "property_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "property_city": { + "name": "property_city", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_state": { + "name": "property_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_zip": { + "name": "property_zip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_amount_cents": { + "name": "total_amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unpaid'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_requests_tenant": { + "name": "idx_inspection_requests_tenant", + "columns": [ + "tenant_id", + "status", + "scheduled_at" + ], + "isUnique": false + }, + "idx_inspection_requests_email": { + "name": "idx_inspection_requests_email", + "columns": [ + "tenant_id", + "client_email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspection_requests_tenant_id_tenants_id_fk": { + "name": "inspection_requests_tenant_id_tenants_id_fk", + "tableFrom": "inspection_requests", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_results": { + "name": "inspection_results", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ydoc_state": { + "name": "ydoc_state", + "type": "blob", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating_system_id": { + "name": "rating_system_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rating_system_snapshot": { + "name": "rating_system_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_results_tenant": { + "name": "idx_results_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_results_inspection": { + "name": "idx_results_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "uq_results_inspection": { + "name": "uq_results_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "inspection_results_tenant_id_tenants_id_fk": { + "name": "inspection_results_tenant_id_tenants_id_fk", + "tableFrom": "inspection_results", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_results_inspection_id_inspections_id_fk": { + "name": "inspection_results_inspection_id_inspections_id_fk", + "tableFrom": "inspection_results", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_services": { + "name": "inspection_services", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_id": { + "name": "service_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_override_cents": { + "name": "price_override_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name_snapshot": { + "name": "name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_snapshot_cents": { + "name": "price_snapshot_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_insp_services_tenant": { + "name": "idx_insp_services_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_insp_services_insp": { + "name": "idx_insp_services_insp", + "columns": [ + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspection_services_tenant_id_tenants_id_fk": { + "name": "inspection_services_tenant_id_tenants_id_fk", + "tableFrom": "inspection_services", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_services_inspection_id_inspections_id_fk": { + "name": "inspection_services_inspection_id_inspections_id_fk", + "tableFrom": "inspection_services", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inspection_services_service_id_services_id_fk": { + "name": "inspection_services_service_id_services_id_fk", + "tableFrom": "inspection_services", + "tableTo": "services", + "columnsFrom": [ + "service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_types": { + "name": "inspection_types", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "based_on": { + "name": "based_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_types_tenant_name": { + "name": "idx_inspection_types_tenant_name", + "columns": [ + "tenant_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_units": { + "name": "inspection_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_unit_id": { + "name": "parent_unit_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unit'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "attrs": { + "name": "attrs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_units_tenant_inspection": { + "name": "idx_inspection_units_tenant_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "idx_inspection_units_parent": { + "name": "idx_inspection_units_parent", + "columns": [ + "parent_unit_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspections": { + "name": "inspections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_address": { + "name": "property_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address_place_id": { + "name": "address_place_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_street": { + "name": "address_street", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_city": { + "name": "address_city", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_state": { + "name": "address_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_zip": { + "name": "address_zip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_county": { + "name": "address_county", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_lat": { + "name": "address_lat", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_lng": { + "name": "address_lng", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_geocoded_at": { + "name": "address_geocoded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'requested'" + }, + "report_status": { + "name": "report_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'in_progress'" + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unpaid'" + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancel_notes": { + "name": "cancel_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_payment_required": { + "name": "is_payment_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_agreement_required": { + "name": "is_agreement_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_auto_sign_on_publish": { + "name": "is_auto_sign_on_publish", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "discount_code_id": { + "name": "discount_code_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "discount_amount_cents": { + "name": "discount_amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "closing_date": { + "name": "closing_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referral_source": { + "name": "referral_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_number": { + "name": "reference_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "internal_notes": { + "name": "internal_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "year_built": { + "name": "year_built", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sqft": { + "name": "sqft", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "foundation_type": { + "name": "foundation_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bedrooms": { + "name": "bedrooms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bathrooms": { + "name": "bathrooms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lot_size": { + "name": "lot_size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_facts": { + "name": "property_facts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_photo_id": { + "name": "cover_photo_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_crop": { + "name": "cover_crop", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_image_key": { + "name": "cover_image_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commercial_subtype": { + "name": "commercial_subtype", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report_tier": { + "name": "report_tier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "county": { + "name": "county", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_automations_disabled": { + "name": "is_automations_disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "template_snapshot": { + "name": "template_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_snapshot_version": { + "name": "template_snapshot_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "profile_override": { + "name": "profile_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "require_defect_fields_override": { + "name": "require_defect_fields_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "concierge_status": { + "name": "concierge_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_team_mode": { + "name": "is_team_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "lead_inspector_id": { + "name": "lead_inspector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "helper_inspector_ids": { + "name": "helper_inspector_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "data_version": { + "name": "data_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "source_inspection_id": { + "name": "source_inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "root_inspection_id": { + "name": "root_inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reinspection_round": { + "name": "reinspection_round", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit_inspection_mode": { + "name": "unit_inspection_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'tagged'" + }, + "location_options": { + "name": "location_options", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sampling_declaration": { + "name": "sampling_declaration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pca_narrative": { + "name": "pca_narrative", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deviations": { + "name": "deviations", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report_photo_mode": { + "name": "report_photo_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_start_ms": { + "name": "scheduled_start_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_end_ms": { + "name": "scheduled_end_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_min": { + "name": "duration_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "badge_layout_override": { + "name": "badge_layout_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report_photo_columns": { + "name": "report_photo_columns", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referred_by_contact_id": { + "name": "referred_by_contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_inspections_tenant": { + "name": "idx_inspections_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_inspections_request": { + "name": "idx_inspections_request", + "columns": [ + "request_id" + ], + "isUnique": false + }, + "idx_inspections_inspector": { + "name": "idx_inspections_inspector", + "columns": [ + "inspector_id" + ], + "isUnique": false + }, + "idx_inspections_tenant_status": { + "name": "idx_inspections_tenant_status", + "columns": [ + "tenant_id", + "status" + ], + "isUnique": false + }, + "idx_inspections_tenant_date": { + "name": "idx_inspections_tenant_date", + "columns": [ + "tenant_id", + "date" + ], + "isUnique": false + }, + "idx_inspections_inspector_date": { + "name": "idx_inspections_inspector_date", + "columns": [ + "inspector_id", + "date" + ], + "isUnique": false + }, + "idx_inspections_root": { + "name": "idx_inspections_root", + "columns": [ + "root_inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspections_tenant_id_tenants_id_fk": { + "name": "inspections_tenant_id_tenants_id_fk", + "tableFrom": "inspections", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_inspector_id_users_id_fk": { + "name": "inspections_inspector_id_users_id_fk", + "tableFrom": "inspections", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_template_id_templates_id_fk": { + "name": "inspections_template_id_templates_id_fk", + "tableFrom": "inspections", + "tableTo": "templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_discount_code_id_discount_codes_id_fk": { + "name": "inspections_discount_code_id_discount_codes_id_fk", + "tableFrom": "inspections", + "tableTo": "discount_codes", + "columnsFrom": [ + "discount_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_request_id_inspection_requests_id_fk": { + "name": "inspections_request_id_inspection_requests_id_fk", + "tableFrom": "inspections", + "tableTo": "inspection_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspector_credentials": { + "name": "inspector_credentials", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "member_number": { + "name": "member_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_r2_key": { + "name": "image_r2_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspector_credentials_tenant": { + "name": "idx_inspector_credentials_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_inspector_credentials_user": { + "name": "idx_inspector_credentials_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invoices": { + "name": "invoices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "line_items": { + "name": "line_items", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_at": { + "name": "sent_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_at": { + "name": "paid_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method": { + "name": "payment_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "partial_paid_at": { + "name": "partial_paid_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voided_at": { + "name": "voided_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "qbo_sync_status": { + "name": "qbo_sync_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + } + }, + "indexes": { + "idx_invoices_tenant": { + "name": "idx_invoices_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_invoices_inspection": { + "name": "idx_invoices_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "idx_invoices_contact": { + "name": "idx_invoices_contact", + "columns": [ + "tenant_id", + "contact_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invoices_tenant_id_tenants_id_fk": { + "name": "invoices_tenant_id_tenants_id_fk", + "tableFrom": "invoices", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invoices_inspection_id_inspections_id_fk": { + "name": "invoices_inspection_id_inspections_id_fk", + "tableFrom": "invoices", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invoices_contact_id_contacts_id_fk": { + "name": "invoices_contact_id_contacts_id_fk", + "tableFrom": "invoices", + "tableTo": "contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "marketplace_libraries": { + "name": "marketplace_libraries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "changelog": { + "name": "changelog", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_count": { + "name": "download_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_featured": { + "name": "is_featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_marketplace_libraries_kind_featured": { + "name": "idx_marketplace_libraries_kind_featured", + "columns": [ + "kind", + "is_featured" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "marketplace_templates": { + "name": "marketplace_templates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "changelog": { + "name": "changelog", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_count": { + "name": "download_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_featured": { + "name": "is_featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "message_templates": { + "name": "message_templates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "variables": { + "name": "variables", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_seeded": { + "name": "is_seeded", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_message_templates_tenant_channel": { + "name": "idx_message_templates_tenant_channel", + "columns": [ + "tenant_id", + "channel" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messaging_compliance": { + "name": "messaging_compliance", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'own'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subaccount_sid": { + "name": "subaccount_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_profile_sid": { + "name": "customer_profile_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "customer_profile_status": { + "name": "customer_profile_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "brand_sid": { + "name": "brand_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "brand_status": { + "name": "brand_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "campaign_sid": { + "name": "campaign_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "campaign_status": { + "name": "campaign_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tfv_sid": { + "name": "tfv_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tfv_status": { + "name": "tfv_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "messaging_resource_sid": { + "name": "messaging_resource_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_meta": { + "name": "provider_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provisioned_number": { + "name": "provisioned_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provisioned_number_sid": { + "name": "provisioned_number_sid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "has_sender_attached": { + "name": "has_sender_attached", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "compliance_status": { + "name": "compliance_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'not_started'" + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_preferences": { + "name": "notification_preferences", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_kind": { + "name": "subject_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "class_id": { + "name": "class_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_notification_prefs_unique": { + "name": "idx_notification_prefs_unique", + "columns": [ + "tenant_id", + "subject_kind", + "subject_id", + "class_id", + "channel" + ], + "isUnique": true + }, + "idx_notification_prefs_subject": { + "name": "idx_notification_prefs_subject", + "columns": [ + "tenant_id", + "subject_kind", + "subject_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "orphaned_media": { + "name": "orphaned_media", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_orphaned_media_key": { + "name": "idx_orphaned_media_key", + "columns": [ + "tenant_id", + "r2_key" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "processed_webhook_events": { + "name": "processed_webhook_events", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "psq_responses": { + "name": "psq_responses", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "responses": { + "name": "responses", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'sent'" + }, + "share_token": { + "name": "share_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_at": { + "name": "sent_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_psq_inspection": { + "name": "uq_psq_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": true + }, + "idx_psq_share_token": { + "name": "idx_psq_share_token", + "columns": [ + "share_token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "qbo_connections": { + "name": "qbo_connections", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "realm_id": { + "name": "realm_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_enc": { + "name": "refresh_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_sync_enabled": { + "name": "is_sync_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "default_item_id": { + "name": "default_item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "qbo_entity_map": { + "name": "qbo_entity_map", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_type": { + "name": "oi_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_id": { + "name": "oi_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qbo_type": { + "name": "qbo_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qbo_id": { + "name": "qbo_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qbo_sync_token": { + "name": "qbo_sync_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "synced_at": { + "name": "synced_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_qbo_entity_map_qbo": { + "name": "idx_qbo_entity_map_qbo", + "columns": [ + "tenant_id", + "qbo_type", + "qbo_id" + ], + "isUnique": true + }, + "idx_qbo_entity_map_oi": { + "name": "idx_qbo_entity_map_oi", + "columns": [ + "tenant_id", + "oi_type", + "oi_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "qbo_sync_errors": { + "name": "qbo_sync_errors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_type": { + "name": "oi_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_id": { + "name": "oi_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_msg": { + "name": "error_msg", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "is_resolved": { + "name": "is_resolved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rating_systems": { + "name": "rating_systems", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "levels": { + "name": "levels", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_seed": { + "name": "is_seed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_rating_systems_tenant_slug": { + "name": "idx_rating_systems_tenant_slug", + "columns": [ + "tenant_id", + "slug" + ], + "isUnique": true + }, + "idx_rating_systems_tenant": { + "name": "idx_rating_systems_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "rating_systems_tenant_id_tenants_id_fk": { + "name": "rating_systems_tenant_id_tenants_id_fk", + "tableFrom": "rating_systems", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repair_request_items": { + "name": "repair_request_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repair_request_id": { + "name": "repair_request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finding_key": { + "name": "finding_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_title": { + "name": "section_title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_label": { + "name": "item_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "comment_snapshot": { + "name": "comment_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "requested_credit_cents": { + "name": "requested_credit_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "defect_title_snapshot": { + "name": "defect_title_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_snapshot": { + "name": "location_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_snapshot": { + "name": "category_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trade_snapshot": { + "name": "trade_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_repair_request_items_rr": { + "name": "idx_repair_request_items_rr", + "columns": [ + "repair_request_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repair_requests": { + "name": "repair_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_kind": { + "name": "created_by_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_ref": { + "name": "created_by_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "custom_intro": { + "name": "custom_intro", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "share_token": { + "name": "share_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_repair_requests_inspection": { + "name": "idx_repair_requests_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "idx_repair_requests_share_token": { + "name": "idx_repair_requests_share_token", + "columns": [ + "share_token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_exports": { + "name": "report_exports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_report_exports_inspection": { + "name": "idx_report_exports_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_pdfs": { + "name": "report_pdfs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rendered_at": { + "name": "rendered_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_version": { + "name": "source_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ready'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_report_pdfs_inspection_type": { + "name": "uq_report_pdfs_inspection_type", + "columns": [ + "inspection_id", + "type", + "version_number" + ], + "isUnique": true + }, + "idx_report_pdfs_tenant": { + "name": "idx_report_pdfs_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_report_pdfs_status": { + "name": "idx_report_pdfs_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_report_pdfs_content_hash": { + "name": "idx_report_pdfs_content_hash", + "columns": [ + "inspection_id", + "type", + "content_hash" + ], + "isUnique": false + } + }, + "foreignKeys": { + "report_pdfs_tenant_id_tenants_id_fk": { + "name": "report_pdfs_tenant_id_tenants_id_fk", + "tableFrom": "report_pdfs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_signoff": { + "name": "report_signoff", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "person_id": { + "name": "person_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "license": { + "name": "license", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "qualifications_ref": { + "name": "qualifications_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_at": { + "name": "signed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signature_ref": { + "name": "signature_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_dual_role": { + "name": "is_dual_role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "idx_report_signoff_inspection": { + "name": "idx_report_signoff_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "uq_report_signoff_role": { + "name": "uq_report_signoff_role", + "columns": [ + "inspection_id", + "role" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_versions": { + "name": "report_versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_json": { + "name": "snapshot_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prev_hash": { + "name": "prev_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_fingerprint": { + "name": "key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_amendment": { + "name": "is_amendment", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "idx_report_versions_inspection": { + "name": "idx_report_versions_inspection", + "columns": [ + "inspection_id", + "version_number" + ], + "isUnique": false + }, + "uq_report_versions_inspection_version": { + "name": "uq_report_versions_inspection_version", + "columns": [ + "inspection_id", + "version_number" + ], + "isUnique": true + }, + "idx_report_versions_verify_token": { + "name": "idx_report_versions_verify_token", + "columns": [ + "verification_token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "service_inspectors": { + "name": "service_inspectors", + "columns": { + "service_id": { + "name": "service_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_service_inspectors_tenant": { + "name": "idx_service_inspectors_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "service_inspectors_service_id_user_id_pk": { + "columns": [ + "service_id", + "user_id" + ], + "name": "service_inspectors_service_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "services": { + "name": "services", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agreement_id": { + "name": "agreement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_services_tenant": { + "name": "idx_services_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "services_tenant_id_tenants_id_fk": { + "name": "services_tenant_id_tenants_id_fk", + "tableFrom": "services", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "services_template_id_templates_id_fk": { + "name": "services_template_id_templates_id_fk", + "tableFrom": "services", + "tableTo": "templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "services_agreement_id_agreements_id_fk": { + "name": "services_agreement_id_agreements_id_fk", + "tableFrom": "services", + "tableTo": "agreements", + "columnsFrom": [ + "agreement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "signing_keys": { + "name": "signing_keys", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key_enc": { + "name": "private_key_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key_iv": { + "name": "private_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Ed25519'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "signing_keys_tenant_id_tenants_id_fk": { + "name": "signing_keys_tenant_id_tenants_id_fk", + "tableFrom": "signing_keys", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sms_consent_log": { + "name": "sms_consent_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_type": { + "name": "recipient_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "disclosure_version": { + "name": "disclosure_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "captured_via": { + "name": "captured_via", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_kind": { + "name": "subject_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'contact'" + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + } + }, + "indexes": { + "idx_sms_consent_contact": { + "name": "idx_sms_consent_contact", + "columns": [ + "tenant_id", + "contact_id", + "created_at" + ], + "isUnique": false + }, + "idx_sms_consent_subject": { + "name": "idx_sms_consent_subject", + "columns": [ + "tenant_id", + "subject_kind", + "subject_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sms_delivery_status": { + "name": "sms_delivery_status", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_message_id": { + "name": "provider_message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_sms_delivery_status_msg": { + "name": "idx_sms_delivery_status_msg", + "columns": [ + "tenant_id", + "provider_message_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sms_disclosure_versions": { + "name": "sms_disclosure_versions", + "columns": { + "version": { + "name": "version", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_seed": { + "name": "is_seed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_tags_tenant_name": { + "name": "idx_tags_tenant_name", + "columns": [ + "tenant_id", + "name" + ], + "isUnique": true + }, + "idx_tags_tenant": { + "name": "idx_tags_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "templates": { + "name": "templates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating_system_id": { + "name": "rating_system_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commercial_subtype": { + "name": "commercial_subtype", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_featured": { + "name": "is_featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_profile_id": { + "name": "default_profile_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_templates_tenant": { + "name": "idx_templates_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_templates_rating_system": { + "name": "idx_templates_rating_system", + "columns": [ + "rating_system_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "templates_tenant_id_tenants_id_fk": { + "name": "templates_tenant_id_tenants_id_fk", + "tableFrom": "templates", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_custom_holidays": { + "name": "tenant_custom_holidays", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uq_tenant_custom_holidays_tenant_date": { + "name": "uq_tenant_custom_holidays_tenant_date", + "columns": [ + "tenant_id", + "date" + ], + "isUnique": true + }, + "idx_tenant_custom_holidays_tenant_date": { + "name": "idx_tenant_custom_holidays_tenant_date", + "columns": [ + "tenant_id", + "date" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_library_imports": { + "name": "tenant_library_imports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_semver": { + "name": "imported_semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_at": { + "name": "imported_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "uq_tenant_library_import": { + "name": "uq_tenant_library_import", + "columns": [ + "tenant_id", + "library_id" + ], + "isUnique": true + }, + "idx_tenant_library_imports_tenant": { + "name": "idx_tenant_library_imports_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_marketplace_import_history": { + "name": "tenant_marketplace_import_history", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_version": { + "name": "source_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_version": { + "name": "target_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rows_affected": { + "name": "rows_affected", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_marketplace_history_tenant": { + "name": "idx_marketplace_history_tenant", + "columns": [ + "tenant_id", + "created_at" + ], + "isUnique": false + }, + "idx_marketplace_history_template": { + "name": "idx_marketplace_history_template", + "columns": [ + "template_id" + ], + "isUnique": false + }, + "idx_marketplace_history_library": { + "name": "idx_marketplace_history_library", + "columns": [ + "library_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_marketplace_imports": { + "name": "tenant_marketplace_imports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "marketplace_template_id": { + "name": "marketplace_template_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_semver": { + "name": "imported_semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_template_id": { + "name": "local_template_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_at": { + "name": "imported_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_mkt_imports_tmpl": { + "name": "idx_mkt_imports_tmpl", + "columns": [ + "marketplace_template_id" + ], + "isUnique": false + }, + "idx_mkt_imports_tenant": { + "name": "idx_mkt_imports_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk": { + "name": "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk", + "tableFrom": "tenant_marketplace_imports", + "tableTo": "marketplace_templates", + "columnsFrom": [ + "marketplace_template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tenant_marketplace_imports_local_template_id_templates_id_fk": { + "name": "tenant_marketplace_imports_local_template_id_templates_id_fk", + "tableFrom": "tenant_marketplace_imports", + "tableTo": "templates", + "columnsFrom": [ + "local_template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_counters": { + "name": "usage_counters", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period_key": { + "name": "period_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_usage_counters_tenant": { + "name": "idx_usage_counters_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_counters_tenant_id_metric_period_key_pk": { + "columns": [ + "tenant_id", + "metric", + "period_key" + ], + "name": "usage_counters_tenant_id_metric_period_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_templates": { + "name": "email_templates", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "blocks": { + "name": "blocks", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "email_templates_tenant_id_tenants_id_fk": { + "name": "email_templates_tenant_id_tenants_id_fk", + "tableFrom": "email_templates", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_templates_tenant_id_trigger_pk": { + "columns": [ + "tenant_id", + "trigger" + ], + "name": "email_templates_tenant_id_trigger_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_configs": { + "name": "tenant_configs", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "support_email": { + "name": "support_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_address": { + "name": "company_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_pdf_footer_shown": { + "name": "is_pdf_footer_shown", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_pdf_page_numbers_shown": { + "name": "is_pdf_page_numbers_shown", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_pdf_license_shown": { + "name": "is_pdf_license_shown", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sender_email": { + "name": "sender_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reply_to": { + "name": "reply_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_mode": { + "name": "email_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'platform'" + }, + "video_mode": { + "name": "video_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'r2'" + }, + "sms_mode": { + "name": "sms_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'platform'" + }, + "sender_display_name": { + "name": "sender_display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "point_of_contact": { + "name": "point_of_contact", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'company'" + }, + "billing_url": { + "name": "billing_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_url": { + "name": "review_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_phone": { + "name": "company_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "integration_config": { + "name": "integration_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secrets_enc": { + "name": "secrets_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dek_enc": { + "name": "dek_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ics_token": { + "name": "ics_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "widget_allowed_origins": { + "name": "widget_allowed_origins", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_profile_id": { + "name": "default_profile_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'signature'" + }, + "attention_thresholds": { + "name": "attention_thresholds", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"agreement_unsigned_h\":72,\"invoice_overdue_h\":72,\"report_unpublished_h\":72}'" + }, + "inspection_prefs": { + "name": "inspection_prefs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_estimates_shown": { + "name": "is_estimates_shown", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_repair_list_enabled": { + "name": "is_repair_list_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_customer_repair_export_enabled": { + "name": "is_customer_repair_export_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_unpaid_blocked": { + "name": "is_unpaid_blocked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_unsigned_agreement_blocked": { + "name": "is_unsigned_agreement_blocked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "custom_referral_sources": { + "name": "custom_referral_sources", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dashboard_column_prefs": { + "name": "dashboard_column_prefs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_concierge_review_required": { + "name": "is_concierge_review_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_inspector_choice_allowed": { + "name": "is_inspector_choice_allowed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_pdf_pipeline_enabled": { + "name": "is_pdf_pipeline_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_team_mode_default": { + "name": "is_team_mode_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_apprentice_review_required": { + "name": "is_apprentice_review_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_guest_invites_enabled": { + "name": "is_guest_invites_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "require_defect_fields": { + "name": "require_defect_fields", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "agreement_retention_years": { + "name": "agreement_retention_years", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 6 + }, + "reinspection_statuses": { + "name": "reinspection_statuses", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_collab_editing_enabled": { + "name": "is_collab_editing_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sms_byo_provider": { + "name": "sms_byo_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_byo_provider": { + "name": "email_byo_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'resend'" + }, + "is_managed_eligible": { + "name": "is_managed_eligible", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "managed_provider": { + "name": "managed_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'twilio'" + }, + "is_reserve_schedule_enabled": { + "name": "is_reserve_schedule_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "reserve_term_years": { + "name": "reserve_term_years", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 12 + }, + "inflation_rate_bps": { + "name": "inflation_rate_bps", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_timezone": { + "name": "default_timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'UTC'" + }, + "booking_slot_mode": { + "name": "booking_slot_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'fixed'" + }, + "booking_slot_interval_min": { + "name": "booking_slot_interval_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "holiday_region": { + "name": "holiday_region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "holiday_public_policy": { + "name": "holiday_public_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "holiday_internal_policy": { + "name": "holiday_internal_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'advisory'" + }, + "default_locale": { + "name": "default_locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en-US'" + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "is_archive_revoking_access": { + "name": "is_archive_revoking_access", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "legal_mode": { + "name": "legal_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'hosted'" + }, + "custom_privacy_url": { + "name": "custom_privacy_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_terms_url": { + "name": "custom_terms_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "privacy_body": { + "name": "privacy_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terms_body": { + "name": "terms_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_configs_tenant_id_tenants_id_fk": { + "name": "tenant_configs_tenant_id_tenants_id_fk", + "tableFrom": "tenant_configs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenants": { + "name": "tenants", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'free'" + }, + "stripe_connect_account_id": { + "name": "stripe_connect_account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "max_users": { + "name": "max_users", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 5 + }, + "deployment_mode": { + "name": "deployment_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'shared'" + }, + "applied_cmd_seq": { + "name": "applied_cmd_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "applied_cred_seq": { + "name": "applied_cred_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tenants_slug_unique": { + "name": "tenants_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_invites": { + "name": "tenant_invites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'inspector'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mentor_id": { + "name": "mentor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assigned_section_ids": { + "name": "assigned_section_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "permission_overrides": { + "name": "permission_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_invites_tenant": { + "name": "idx_invites_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_tenant_invites_pending_email": { + "name": "uq_tenant_invites_pending_email", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": true, + "where": "status = 'pending'" + } + }, + "foreignKeys": { + "tenant_invites_tenant_id_tenants_id_fk": { + "name": "tenant_invites_tenant_id_tenants_id_fk", + "tableFrom": "tenant_invites", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "license_number": { + "name": "license_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_signature_base64": { + "name": "default_signature_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_signature_enabled": { + "name": "is_signature_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manager'" + }, + "onboarding_state": { + "name": "onboarding_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_totp_enabled": { + "name": "is_totp_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "totp_recovery_codes": { + "name": "totp_recovery_codes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totp_verified_at": { + "name": "totp_verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mentor_id": { + "name": "mentor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assigned_section_ids": { + "name": "assigned_section_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terms_accepted": { + "name": "terms_accepted", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permission_overrides": { + "name": "permission_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + "deleted_at" + ], + "isUnique": false + }, + "uq_users_tenant_email": { + "name": "uq_users_tenant_email", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": true, + "where": "deleted_at IS NULL" + }, + "idx_users_tenant": { + "name": "idx_users_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_users_slug_per_tenant": { + "name": "idx_users_slug_per_tenant", + "columns": [ + "tenant_id", + "slug" + ], + "isUnique": true + }, + "idx_users_email": { + "name": "idx_users_email", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "users_tenant_id_tenants_id_fk": { + "name": "users_tenant_id_tenants_id_fk", + "tableFrom": "users", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_slug": { + "name": "inspector_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_audit_tenant_created": { + "name": "idx_audit_tenant_created", + "columns": [ + "tenant_id", + "created_at" + ], + "isUnique": false + }, + "idx_audit_entity": { + "name": "idx_audit_entity", + "columns": [ + "entity_type", + "entity_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_logs_tenant_id_tenants_id_fk": { + "name": "audit_logs_tenant_id_tenants_id_fk", + "tableFrom": "audit_logs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration_test_results": { + "name": "integration_test_results", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_ok": { + "name": "is_ok", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tested_by_user_id": { + "name": "tested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tested_at": { + "name": "tested_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_integration_test_tenant_target": { + "name": "idx_integration_test_tenant_target", + "columns": [ + "tenant_id", + "target", + "tested_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_notifications_tenant_user_created": { + "name": "idx_notifications_tenant_user_created", + "columns": [ + "tenant_id", + "user_id", + "created_at" + ], + "isUnique": false + }, + "idx_notifications_tenant_user_unread": { + "name": "idx_notifications_tenant_user_unread", + "columns": [ + "tenant_id", + "user_id", + "read_at" + ], + "isUnique": false + }, + "idx_notifications_tenant_contact_created": { + "name": "idx_notifications_tenant_contact_created", + "columns": [ + "tenant_id", + "contact_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notifications_tenant_id_tenants_id_fk": { + "name": "notifications_tenant_id_tenants_id_fk", + "tableFrom": "notifications", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "parked_cmd_events": { + "name": "parked_cmd_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "envelope": { + "name": "envelope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_parked_cmd_events_received_at": { + "name": "idx_parked_cmd_events_received_at", + "columns": [ + "received_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "processed_cmd_events": { + "name": "processed_cmd_events", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cmd_type": { + "name": "cmd_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "processed_at": { + "name": "processed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "slug_reservations": { + "name": "slug_reservations", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_outbox": { + "name": "sync_outbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_tried_at": { + "name": "last_tried_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_sync_outbox_status_created": { + "name": "idx_sync_outbox_status_created", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_destruction_records": { + "name": "tenant_destruction_records", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_slug": { + "name": "tenant_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rows_deleted": { + "name": "rows_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "r2_objects": { + "name": "r2_objects", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "r2_bytes": { + "name": "r2_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "kv_keys": { + "name": "kv_keys", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "destroyed_at": { + "name": "destroyed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_destruction_tenant": { + "name": "idx_destruction_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_destruction_destroyed_at": { + "name": "idx_destruction_destroyed_at", + "columns": [ + "destroyed_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_legal_versions": { + "name": "tenant_legal_versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "doc": { + "name": "doc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body_snapshot": { + "name": "body_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_material": { + "name": "is_material", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_by_user_id": { + "name": "published_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_tenant_legal_versions_doc_version": { + "name": "idx_tenant_legal_versions_doc_version", + "columns": [ + "tenant_id", + "doc", + "version" + ], + "isUnique": true + }, + "idx_tenant_legal_versions_latest": { + "name": "idx_tenant_legal_versions_latest", + "columns": [ + "tenant_id", + "doc", + "published_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "client_uploads": { + "name": "client_uploads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_kind": { + "name": "uploaded_by_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_ref": { + "name": "uploaded_by_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_by_name": { + "name": "uploaded_by_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_client_uploads_inspection": { + "name": "idx_client_uploads_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "uq_discount_codes_code_tenant": { + "columns": { + "upper(code)": { + "isExpression": true + } + } + } + } + } +} diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 5859e7062..a76662d47 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1785515514924, "tag": "0022_license_number_backfill", "breakpoints": true + }, + { + "idx": 23, + "version": "6", + "when": 1785515515924, + "tag": "0023_notification_pref_enabled_rename", + "breakpoints": true } ] } diff --git a/server/lib/db/schema/notification-preferences.ts b/server/lib/db/schema/notification-preferences.ts index d4c6e9586..36f8220e1 100644 --- a/server/lib/db/schema/notification-preferences.ts +++ b/server/lib/db/schema/notification-preferences.ts @@ -37,7 +37,10 @@ export const notificationPreferences = sqliteTable('notification_preferences', { /** A `NOTIFICATION_CLASSES` id. Not a template trigger — those are a subset. */ classId: text('class_id').notNull(), channel: text('channel', { enum: ['email', 'sms', 'in_app'] }).notNull(), - enabled: integer('enabled', { mode: 'boolean' }).notNull(), + // DB column is `is_enabled` per the naming rule; the drizzle property stays + // `enabled` so every call site and the API field keep reading as the plain + // English question they answer. + enabled: integer('is_enabled', { mode: 'boolean' }).notNull(), createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(), }, (t) => [ From 449902649eb909efce87d9b3167398c993438898 Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 1 Aug 2026 07:42:32 +0800 Subject: [PATCH 35/48] =?UTF-8?q?fix(reports):=20one=20person,=20one=20sou?= =?UTF-8?q?rce=20=E2=80=94=20the=20pinned=20name=20and=20licence=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A defect in 20e1ae8d, found by checking a scope claim I had made rather than by anything failing. Pinning the badge strip while leaving the two fields BESIDE it to resolve live meant a pinned per-version read produced three answers about one person from three places: inspectorCredentials frozen, from the snapshot inspectorLicense live, via primaryLicenseNumber on current rows inspectorName live An inspector who renews their licence therefore gets a document showing the old number in the cover strip and the new one on the signature block. Same page, two numbers, both ours. That is the "two sources for one line" failure I wrote a commit message about two commits earlier, reintroduced in the same document. `pinnedLead` replaces `pinnedLeadCredentials` and returns the WHOLE person, because the fix is not "pin one more field" — it is that name, licence and badges are three facts about one person and have to come from one place or the report contradicts itself. The null-vs-`[]` distinction survives intact: null means the snapshot cannot answer (no version pinned, a v1 row, an empty list) and live applies; a lead with `credentials: []` is a real answer and must NOT fall through, or live state resurrects badges the delivered document never carried. `primaryLicenseOf` is now a free function over a list rather than a method that queries. That is what made the bug possible: the rule lived inside the DB read, so the pinned path could not reach it and called the live one instead. One rule, two sources. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HcJgdRoQzrRJ8RvEcpUDR5 --- server/lib/version-diff.ts | 38 +++---- server/services/credential.service.ts | 23 +++- .../inspection/inspection-report.service.ts | 47 ++++---- .../report-pinned-version-read.spec.ts | 101 +++++++++++++----- 4 files changed, 136 insertions(+), 73 deletions(-) diff --git a/server/lib/version-diff.ts b/server/lib/version-diff.ts index 95368472b..47770e487 100644 --- a/server/lib/version-diff.ts +++ b/server/lib/version-diff.ts @@ -55,30 +55,30 @@ export interface SnapshotInspector { export const SNAPSHOT_SCHEMA_VERSION = 2; /** - * The credentials a PINNED version should render, or null when the snapshot - * cannot answer. + * The inspector a PINNED version credits, or null when the snapshot cannot say. * - * Null and `[]` are different answers and the caller must not conflate them: + * Returns the WHOLE person, not just their badges, because everything the report + * shows about them has to come from the same place. Pinning the badge strip and + * leaving the name and licence line to resolve live produces one document + * carrying two answers about one person — an inspector who renews their licence + * gets the old number in the strip and the new one on the signature block. * - * null — this snapshot predates the credential capture (schema v1), so there - * is nothing recorded and the live state is the only thing there is to - * show. Those reports WERE rendered live when they were delivered; - * pretending otherwise would be inventing history rather than - * recording it. - * [] — the inspector held no credentials on publish day. A real answer, and - * rendering live state over it would resurrect badges the delivered - * document never carried. + * Null means "this snapshot cannot answer": no version pinned, a v1 row written + * before inspectors were captured, or an empty list. Live resolution applies in + * all three. It does NOT mean "held no credentials" — that is a lead whose + * `credentials` is `[]`, which is a real answer, and rendering live state over + * it would resurrect badges the delivered document never carried. * - * OPTION A on the cover: the LEAD's badges only, matching the report's single - * inspector name and single signer. The snapshot keeps the helpers' too, so - * crediting them later is a rendering decision rather than a migration. + * OPTION A on the cover: the LEAD only, matching the report's single inspector + * name and single signer. The snapshot keeps the helpers, so crediting them + * later is a rendering decision rather than a migration. */ -export function pinnedLeadCredentials( +export function pinnedLead( snapshot: Snapshot | null | undefined, -): SnapshotInspector['credentials'] | null { - if (!snapshot?.inspectors) return null; - const lead = snapshot.inspectors.find((i) => i.role === 'lead') ?? snapshot.inspectors[0]; - return lead?.credentials ?? []; +): SnapshotInspector | null { + const inspectors = snapshot?.inspectors; + if (!inspectors?.length) return null; + return inspectors.find((i) => i.role === 'lead') ?? inspectors[0] ?? null; } export interface Snapshot { diff --git a/server/services/credential.service.ts b/server/services/credential.service.ts index dd4e22c29..5cd07be9d 100644 --- a/server/services/credential.service.ts +++ b/server/services/credential.service.ts @@ -16,6 +16,25 @@ export type InspectorCredential = InferSelectModel; * `inspectorSignature()` does — because a relative path in an email resolves * against the recipient's mail client, which is nowhere. */ +/** + * The LICENCE among a set of credentials, or null. + * + * A free function over the list rather than a method that queries, because two + * callers need the same answer from two different SOURCES: the live report reads + * current rows, and a pinned published version reads the ones its snapshot + * froze. When the rule lived inside the DB method, the pinned path could not + * reach it — so it called the live one, and a report ended up showing a frozen + * badge strip beside a licence line resolved from today. Same document, two + * numbers, for an inspector who had renewed. + * + * "First entry carrying a member number, in the inspector's own order" works + * because the backfill seeds the licence at `sort_order = -1`; that sort order + * was chosen for exactly this. + */ +export function primaryLicenseOf(credentials: RenderableCredential[]): string | null { + return credentials.find((c) => (c.memberNumber ?? '').trim())?.memberNumber?.trim() || null; +} + export interface RenderableCredential { label: string; memberNumber: string | null; @@ -78,9 +97,7 @@ export class CredentialService { * printing an empty one. */ async primaryLicenseNumber(tenantId: string, userId: string): Promise { - const rows = await this.listByUser(tenantId, userId); - const licensed = rows.find((cr) => cr.active && (cr.memberNumber ?? '').trim()); - return licensed?.memberNumber?.trim() || null; + return primaryLicenseOf(await this.listRenderable(tenantId, userId)); } async create( diff --git a/server/services/inspection/inspection-report.service.ts b/server/services/inspection/inspection-report.service.ts index 324658b48..2f03ff114 100644 --- a/server/services/inspection/inspection-report.service.ts +++ b/server/services/inspection/inspection-report.service.ts @@ -1,8 +1,8 @@ import { drizzle } from 'drizzle-orm/d1'; import { eq, and, desc, asc } from 'drizzle-orm'; import { inspections, inspectionResults, templates, users, tenantConfigs, reportVersions, inspectionUnits } from '../../lib/db/schema'; -import { CredentialService } from '../credential.service'; -import { pinnedLeadCredentials } from '../../lib/version-diff'; +import { CredentialService, primaryLicenseOf, type RenderableCredential } from '../credential.service'; +import { pinnedLead } from '../../lib/version-diff'; import { loadPinnedSnapshot } from '../../lib/report-snapshot'; import { buildUnitConditionMatrix, defectCountsByUnit } from '../../lib/unit-scope'; import { Errors } from '../../lib/errors'; @@ -476,38 +476,31 @@ export class InspectionReportService extends InspectionSubService { const numberedSections = photoNumbering.sections as typeof sections; const photoAppendix: AppendixPhoto[] = photoNumbering.appendix; + // THE INSPECTOR, RESOLVED ONCE. Name, licence and badges are three facts + // about one person on one document, so they come from one source or the + // report contradicts itself: pinning the badges alone gave a renewed + // inspector the old number in the cover strip and the new one on the + // signature block. + // + // `users.license_number` is frozen; the licence is a credential row now, + // seeded ahead of the voluntary badges by the backfill. + const lead = pinnedLead(pinned); let inspectorName: string | null = null; let inspectorLicense: string | null = null; - if (inspection.inspectorId) { + let credentialSnapshot: RenderableCredential[] = []; + if (lead) { + inspectorName = lead.name; + inspectorLicense = primaryLicenseOf(lead.credentials); + credentialSnapshot = lead.credentials; + } else if (inspection.inspectorId) { const inspector = await db.select({ name: users.name, email: users.email }) .from(users).where(eq(users.id, inspection.inspectorId)).get(); inspectorName = inspector?.name || (inspector?.email?.split('@')[0] ?? null); - // `users.license_number` is frozen. The licence is a credential row - // now, seeded ahead of the voluntary badges by the backfill. - inspectorLicense = await new CredentialService(this.db) - .primaryLicenseNumber(tenantId, inspection.inspectorId); + credentialSnapshot = await new CredentialService(this.db) + .listRenderable(tenantId, inspection.inspectorId); + inspectorLicense = primaryLicenseOf(credentialSnapshot); } - // Inspector Credentials & Association Badges (Spec B). - // - // A PINNED VERSION RENDERS WHAT IT FROZE. Resolving these live — which is - // what happened before the snapshot carried them — meant an inspector who - // left an association silently rewrote the cover of every report they had - // ever delivered. Option A on the cover: only the LEAD's badges render, - // matching the report's single inspector name and single signer. The - // snapshot keeps the helpers' too, so crediting them later is a rendering - // decision rather than a migration. - // `null` means the snapshot cannot answer (v1, or no version pinned) and - // live is the only thing there is to show; `[]` means the inspector held - // none on publish day, and rendering live over it would resurrect badges - // the delivered document never carried. - const frozenCredentials = pinnedLeadCredentials(pinned); - const credentialSnapshot: Array<{ label: string; memberNumber: string | null; imageUrl: string | null }> = - frozenCredentials - ?? (inspection.inspectorId - ? await new CredentialService(this.db).listRenderable(tenantId, inspection.inspectorId) - : []); - // Sprint 2 S2-4 — per-tenant flag controls whether the published // report renders "Estimated cost: $X – $Y" badges on defect cards. let showEstimates = false; diff --git a/tests/unit/reports/report-pinned-version-read.spec.ts b/tests/unit/reports/report-pinned-version-read.spec.ts index 0039a7343..413895451 100644 --- a/tests/unit/reports/report-pinned-version-read.spec.ts +++ b/tests/unit/reports/report-pinned-version-read.spec.ts @@ -15,7 +15,8 @@ import { describe, it, expect } from 'vitest'; import { signRenderToken, verifyRenderToken } from '../../../server/lib/render-token'; import { buildRenderReportUrl } from '../../../server/lib/public-urls'; -import { pinnedLeadCredentials, type Snapshot } from '../../../server/lib/version-diff'; +import { pinnedLead, type Snapshot } from '../../../server/lib/version-diff'; +import { primaryLicenseOf } from '../../../server/services/credential.service'; const SECRET = 'test-secret'; const INSPECTION = 'insp-1'; @@ -75,58 +76,110 @@ describe('buildRenderReportUrl', () => { }); /** - * Which credentials a pinned read renders. + * Who a pinned read credits, and with what. * - * NULL AND `[]` ARE DIFFERENT ANSWERS, and conflating them is the whole hazard: - * one means "this report predates the capture, live state is all there is", the - * other means "the inspector held none on publish day". As JSON they look - * identical; on a cover page they are opposites, and the wrong one either hides - * a badge a document carried or resurrects one it never did. + * NULL AND `[]` ARE DIFFERENT ANSWERS, and conflating them is the hazard on the + * credentials: one means "this report predates the capture, live is all there + * is", the other means "the inspector held none on publish day". As JSON they + * look identical; on a cover page they are opposites, and the wrong one either + * hides a badge a document carried or resurrects one it never did. */ -describe('pinnedLeadCredentials', () => { - const cred = (label: string) => ({ label, memberNumber: null, imageUrl: null }); +describe('pinnedLead', () => { + const cred = (label: string, memberNumber: string | null = null) => ({ label, memberNumber, imageUrl: null }); const snap = (inspectors?: Snapshot['inspectors']): Snapshot => ({ data: {}, units: [], ...(inspectors ? { inspectors } : {}) }); it('returns null when nothing is pinned at all', () => { - expect(pinnedLeadCredentials(null)).toBeNull(); - expect(pinnedLeadCredentials(undefined)).toBeNull(); + expect(pinnedLead(null)).toBeNull(); + expect(pinnedLead(undefined)).toBeNull(); }); it('returns null for a v1 snapshot, so live fills in', () => { // Those reports WERE rendered live when they were delivered. Serving an // empty strip instead would be inventing history, not recording it. - expect(pinnedLeadCredentials(snap())).toBeNull(); + expect(pinnedLead(snap())).toBeNull(); }); - it('returns an EMPTY LIST when the lead held none — not null', () => { + it('returns null for an empty inspector list', () => { + expect(pinnedLead(snap([]))).toBeNull(); + }); + + it('returns a lead who held NO credentials — that is a real answer, not a gap', () => { // The distinction that stops live state leaking back into a frozen - // document. `?? live` on a null is the fallback; `?? live` on `[]` is not. - const out = pinnedLeadCredentials(snap([ + // document: this must not be null, or the `?? live` fallback fires. + const lead = pinnedLead(snap([ { userId: 'u1', name: 'Dana', role: 'lead', credentials: [] }, ])); - expect(out).toEqual([]); - expect(out).not.toBeNull(); + expect(lead).not.toBeNull(); + expect(lead!.credentials).toEqual([]); }); - it('renders the LEAD only, whatever order the inspectors are in', () => { - const out = pinnedLeadCredentials(snap([ + it('picks the LEAD, whatever order the inspectors are in', () => { + const lead = pinnedLead(snap([ { userId: 'u2', name: 'Sam', role: 'helper', credentials: [cred('Helper cert')] }, { userId: 'u1', name: 'Dana', role: 'lead', credentials: [cred('Lead cert')] }, ])); // Option A. Pooling both would put an unattributed claim on the cover // that neither person made. - expect(out!.map((c) => c.label)).toEqual(['Lead cert']); + expect(lead!.userId).toBe('u1'); + expect(lead!.credentials.map((c) => c.label)).toEqual(['Lead cert']); }); it('falls back to the first inspector when no one is marked lead', () => { - const out = pinnedLeadCredentials(snap([ + const lead = pinnedLead(snap([ { userId: 'u2', name: 'Sam', role: 'helper', credentials: [cred('Only cert')] }, ])); - expect(out!.map((c) => c.label)).toEqual(['Only cert']); + expect(lead!.userId).toBe('u2'); + }); + + /** + * The defect this shape exists to prevent. + * + * Name, licence and badges are three facts about ONE person on ONE document. + * Pinning the badge strip and leaving the other two to resolve live gave a + * renewed inspector the old number in the strip and the new one on the + * signature block — the same document asserting two licence numbers. + */ + it('carries the name and the licence alongside the badges, from one source', () => { + const lead = pinnedLead(snap([{ + userId: 'u1', name: 'Dana Lead', role: 'lead', + credentials: [cred('Licensed home inspector', 'TX-9001'), cred('InterNACHI CPI', 'N-1')], + }]))!; + expect(lead.name).toBe('Dana Lead'); + expect(primaryLicenseOf(lead.credentials)).toBe('TX-9001'); + expect(lead.credentials).toHaveLength(2); + }); +}); + +/** + * `primaryLicenseOf` is a free function over a LIST precisely so the live path + * and the pinned path can apply one rule to two different sources. When it lived + * inside the DB method, the pinned path could not reach it. + */ +describe('primaryLicenseOf', () => { + const c = (label: string, memberNumber: string | null) => ({ label, memberNumber, imageUrl: null }); + + it('takes the first entry carrying a member number, in order', () => { + // Order is the inspector's own, and the backfill seeds the licence at + // sort_order -1 — which is why "first" means "the licence". + expect(primaryLicenseOf([ + c('Licensed home inspector', 'TX-9001'), + c('InterNACHI CPI', 'N-1'), + ])).toBe('TX-9001'); + }); + + it('skips entries with no number — a badge image is not a licence', () => { + expect(primaryLicenseOf([ + c('Association logo', null), + c('Licensed home inspector', 'TX-9001'), + ])).toBe('TX-9001'); + }); + + it('treats a blank number as absent', () => { + expect(primaryLicenseOf([c('Licensed home inspector', ' ')])).toBeNull(); }); - it('survives an inspectors array that is present but empty', () => { - expect(pinnedLeadCredentials(snap([]))).toEqual([]); + it('returns null for an empty list, so the caller omits the line', () => { + expect(primaryLicenseOf([])).toBeNull(); }); }); From bf35b7c9f56e61afff69ea9ebe28c00ec93b1116 Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 1 Aug 2026 07:46:41 +0800 Subject: [PATCH 36/48] feat(gates): enforce the agent- route prefix that loginPathFor depends on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `loginPathFor` decides which sign-in page a session ends on by reading the request path. That removed a per-caller argument a new route could forget — but it only MOVED the forgetting. A page mounted inside `agent-layout` without the prefix silently gets the STAFF login, which has no account for an agent and, under `APP_MODE=saas`, bounces on to the portal's sign-in. Nothing would fail: the session specs pin the routes that exist today, and a wrong redirect is a semantically wrong string, not a type error. So the convention now has an enforcer. One rule, one file, no exemptions list. The specs deliberately cover how a gate like this fails SILENTLY rather than just the happy path, because that is the failure this repository keeps hitting: it fails loudly when the layout block is missing, and when the block contains no routes at all. The obvious implementation — slice to the first `]` — would stop inside a nested layout and skip everything after it while printing OK; there is a spec for that too. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HcJgdRoQzrRJ8RvEcpUDR5 --- package.json | 5 +- scripts/check-agent-routes.mjs | 77 +++++++++++++ .../unit/platform/check-agent-routes.spec.ts | 103 ++++++++++++++++++ 3 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 scripts/check-agent-routes.mjs create mode 100644 tests/unit/platform/check-agent-routes.spec.ts diff --git a/package.json b/package.json index 78778d325..9a3415823 100644 --- a/package.json +++ b/package.json @@ -38,8 +38,9 @@ "type-check": "npm run i18n:compile && react-router typegen && npm run type-check:app && npm run type-check:api", "type-check:app": "node --max-old-space-size=8192 ./node_modules/typescript/bin/tsc -p tsconfig.json --noEmit --incremental --tsBuildInfoFile ./.tsbuildinfo.app", "type-check:api": "node --max-old-space-size=8192 ./node_modules/typescript/bin/tsc -p tsconfig.api.json --noEmit --incremental --tsBuildInfoFile ./.tsbuildinfo.api", - "lint": "cross-env NODE_OPTIONS=--max-old-space-size=12288 eslint . --cache --cache-strategy content && npm run lint:ds && npm run lint:svg && npm run lint:erasure && npm run lint:migrefs && npm run lint:english && npm run lint:filesize && npm run lint:dup && npm run lint:tenant-scope && npm run lint:status-literals && npm run lint:capability-decl && npm run lint:provider-helpers && npm run lint:notification-dispatch && npm run lint:tests && npm run lint:deadcode && npm run lint:timestamps && npm run lint:tz && npm run lint:i18n && npm run lint:i18n-catalog && npm run lint:naming", + "lint": "cross-env NODE_OPTIONS=--max-old-space-size=12288 eslint . --cache --cache-strategy content && npm run lint:ds && npm run lint:svg && npm run lint:erasure && npm run lint:migrefs && npm run lint:english && npm run lint:filesize && npm run lint:dup && npm run lint:tenant-scope && npm run lint:status-literals && npm run lint:capability-decl && npm run lint:provider-helpers && npm run lint:notification-dispatch && npm run lint:tests && npm run lint:deadcode && npm run lint:timestamps && npm run lint:tz && npm run lint:i18n && npm run lint:i18n-catalog && npm run lint:naming && npm run lint:agent-routes", "lint:ds": "node scripts/check-ds-tokens.mjs", + "lint:agent-routes": "node scripts/check-agent-routes.mjs", "lint:naming": "node scripts/check-naming.mjs", "lint:svg": "node scripts/check-svg-dimensions.mjs", "lint:erasure": "node scripts/check-erasure-manifest.mjs", @@ -91,7 +92,7 @@ "mcp:snapshot": "node scripts/snapshot-openapi.mjs", "lint:english": "node scripts/check-english-only.mjs", "lint:eslint": "cross-env NODE_OPTIONS=--max-old-space-size=12288 eslint . --cache --cache-strategy content", - "lint:gates-full": "npm run lint:ds && npm run lint:svg && npm run lint:erasure && npm run lint:migrefs && npm run lint:english && npm run lint:filesize && npm run lint:dup && npm run lint:tenant-scope && npm run lint:status-literals && npm run lint:capability-decl && npm run lint:provider-helpers && npm run lint:notification-dispatch && npm run lint:tests && npm run lint:deadcode && npm run lint:timestamps && npm run lint:tz && npm run lint:i18n && npm run lint:i18n-catalog && npm run lint:naming", + "lint:gates-full": "npm run lint:ds && npm run lint:svg && npm run lint:erasure && npm run lint:migrefs && npm run lint:english && npm run lint:filesize && npm run lint:dup && npm run lint:tenant-scope && npm run lint:status-literals && npm run lint:capability-decl && npm run lint:provider-helpers && npm run lint:notification-dispatch && npm run lint:tests && npm run lint:deadcode && npm run lint:timestamps && npm run lint:tz && npm run lint:i18n && npm run lint:i18n-catalog && npm run lint:naming && npm run lint:agent-routes", "i18n:compile:cached": "node scripts/i18n-compile-if-changed.mjs" }, "dependencies": { diff --git a/scripts/check-agent-routes.mjs b/scripts/check-agent-routes.mjs new file mode 100644 index 000000000..0187abb5f --- /dev/null +++ b/scripts/check-agent-routes.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +/** + * Agent-route prefix gate. + * + * `app/lib/session.server.ts` decides which sign-in page a session ends on by + * reading the request path: anything under `agent-` goes to `/agent-login`, + * everything else to `/login`. That derivation exists because the alternative — + * every caller passing the door in — is a thing a new route can forget, and the + * failure is invisible: an agent lands on the STAFF login, which has no account + * for them, and under `APP_MODE=saas` bounces on to the portal's sign-in, out of + * this product entirely. + * + * Deriving it from the path only moved the forgetting, though. A route mounted + * inside `agent-layout` WITHOUT the prefix silently gets the staff door, and no + * unit test would notice, because the specs pin the routes that exist today. + * This gate is the part that cannot be forgotten. + * + * Rule: every child of `layout("routes/agent-layout.tsx", [...])` must have a + * path starting with `agent-`. Nothing else is checked — this is not a general + * naming gate, it is the enforcement half of one function's assumption. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const ROOT = new URL("..", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"); +const ROUTES = join(ROOT, "app", "routes.ts"); +const LAYOUT = "routes/agent-layout.tsx"; +const PREFIX = "agent-"; + +/** @returns {string[]} human-readable violation messages */ +export function findAgentRouteViolations(source) { + const out = []; + const start = source.indexOf(`layout("${LAYOUT}"`); + if (start === -1) { + // Fail loudly rather than passing vacuously. A gate that silently finds + // nothing to check is the failure mode this repository has been bitten by. + return [`agent-layout block not found in app/routes.ts (looked for layout("${LAYOUT}")`]; + } + + // Walk from the opening bracket of the children array to its match, so a + // nested array or a later layout() cannot end the block early. + const open = source.indexOf("[", start); + let depth = 0; + let end = -1; + for (let i = open; i < source.length; i++) { + if (source[i] === "[") depth++; + else if (source[i] === "]") { + depth--; + if (depth === 0) { end = i; break; } + } + } + if (end === -1) return ["could not find the end of the agent-layout children array"]; + + const block = source.slice(open, end); + const routes = [...block.matchAll(/\broute\(\s*"([^"]+)"/g)].map((m) => m[1]); + if (routes.length === 0) { + return ["agent-layout has no route() children — the gate is matching nothing"]; + } + for (const path of routes) { + if (!path.startsWith(PREFIX)) { + out.push( + `app/routes.ts: "${path}" is mounted inside ${LAYOUT} but does not start with "${PREFIX}" — ` + + `loginPathFor() would send this page's visitors to the STAFF login`, + ); + } + } + return out; +} + +const violations = findAgentRouteViolations(readFileSync(ROUTES, "utf8")); +if (violations.length > 0) { + console.error("\nAgent-route prefix gate FAILED:\n"); + for (const v of violations) console.error(` ${v}`); + console.error(""); + process.exit(1); +} +console.log(`agent-route gate OK`); diff --git a/tests/unit/platform/check-agent-routes.spec.ts b/tests/unit/platform/check-agent-routes.spec.ts new file mode 100644 index 000000000..7911f0e1a --- /dev/null +++ b/tests/unit/platform/check-agent-routes.spec.ts @@ -0,0 +1,103 @@ +/** + * Unit tests for the agent-route prefix gate. + * + * `loginPathFor` (app/lib/session.server.ts) decides which sign-in page a + * session ends on by reading the request path — anything under `agent-` goes to + * `/agent-login`. That derivation removed a per-caller argument a new route + * could forget, but it only MOVED the forgetting: a page mounted inside + * `agent-layout` without the prefix silently gets the STAFF login, which has no + * account for an agent and, in SaaS, bounces on to the portal's sign-in. + * + * No unit test would catch that, because the session specs pin the routes that + * exist today. This gate is the part that cannot be forgotten — so these specs + * cover the ways a gate like this fails SILENTLY, not just the happy path. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { pathToFileURL } from 'node:url'; +import path from 'node:path'; + +let findAgentRouteViolations: (source: string) => string[]; + +beforeAll(async () => { + const scriptPath = path.resolve( + import.meta.dirname ?? path.join(process.cwd()), + '../../../scripts/check-agent-routes.mjs', + ); + // @vite-ignore — load the .mjs via native Node import; vitest's transform + // cannot process this script (esbuild target) and throws a SyntaxError. + ({ findAgentRouteViolations } = await import(/* @vite-ignore */ pathToFileURL(scriptPath).href)); +}); + +const wrap = (children: string) => ` +export default [ + route("login", "routes/login.tsx"), + route("agent-login", "routes/agent/login.tsx"), + layout("routes/auth-layout.tsx", [ + route("inspections", "routes/inspections.tsx"), + ]), + layout("routes/agent-layout.tsx", [ +${children} + ]), +] satisfies RouteConfig; +`; + +describe('findAgentRouteViolations', () => { + it('passes when every child carries the prefix', () => { + expect(findAgentRouteViolations(wrap(` + route("agent-dashboard", "routes/agent/dashboard.tsx"), + route("agent-settings/profile", "routes/agent/settings-profile.tsx"), + `))).toEqual([]); + }); + + it('flags a child without the prefix, and says what would go wrong', () => { + const out = findAgentRouteViolations(wrap(` + route("agent-dashboard", "routes/agent/dashboard.tsx"), + route("partner-inspectors", "routes/agent/inspectors.tsx"), + `)); + expect(out).toHaveLength(1); + expect(out[0]).toContain('partner-inspectors'); + // The message has to name the consequence, or a reader "fixes" it by + // adding an exemption. + expect(out[0]).toContain('STAFF login'); + }); + + it('does not look at routes OUTSIDE the agent layout', () => { + // `/login` and `/inspections` are staff pages and must stay untouched — + // a gate that flagged them would be renamed or deleted within a week. + expect(findAgentRouteViolations(wrap(` + route("agent-dashboard", "routes/agent/dashboard.tsx"), + `))).toEqual([]); + }); + + it('does not end the block early on a NESTED array', () => { + // The obvious implementation slices to the first `]`, which would stop + // inside the nested layout and silently skip everything after it — + // passing while covering nothing. + const out = findAgentRouteViolations(` +export default [ + layout("routes/agent-layout.tsx", [ + layout("routes/agent-sub.tsx", [ + route("agent-nested-ok", "routes/agent/a.tsx"), + ]), + route("wrong-after-nested", "routes/agent/b.tsx"), + ]), +] satisfies RouteConfig; + `); + expect(out).toHaveLength(1); + expect(out[0]).toContain('wrong-after-nested'); + }); + + it('FAILS LOUDLY when the layout block is missing', () => { + // Silence is not success. A gate that quietly finds nothing to inspect + // — because the file was restructured or the layout renamed — prints + // OK forever while covering nothing. + const out = findAgentRouteViolations(`export default [ route("login", "x") ] satisfies RouteConfig;`); + expect(out).toHaveLength(1); + expect(out[0]).toContain('not found'); + }); + + it('FAILS LOUDLY when the layout has no route children', () => { + expect(findAgentRouteViolations(wrap(' // nothing here yet'))[0]) + .toContain('matching nothing'); + }); +}); From fe55a3218261e840127966beb11dfe4a74d2f680 Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 1 Aug 2026 08:23:18 +0800 Subject: [PATCH 37/48] fix(ui): three things only a browser could show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by opening the pages, which is the gate no hook can run. All three were invisible to type-check, to the unit suites and to every conformance gate. THE SIGNATURE PREVIEW WAS DARK-ON-DARK. `inspectorSignature()` bakes literal colours into its HTML (`#0f172a` text, an `#e2e8f0` rule) and has to — a mail client has none of our tokens, and the footer must read correctly in an inbox. Dropped straight onto a themed card, that HTML was near-black on near-black in dark mode: the one surface whose entire job is showing what the recipient sees, showing nothing. The swatch now carries the MEDIUM's background rather than the app's, which is also more honest about what it is previewing. THE CREDENTIAL UPLOADER WAS THE WRONG SIZE FOR ITS COLUMN. `LogoUploader` is the Media Studio company-logo control: a wide row, 112px preview plus a text column plus 20px padding — more than the ~144px credential cell has before the caption gets a pixel. So the preview collapsed to a vertical sliver with the button and caption floating off-centre beside it. An earlier pass had widened the cell w-24 -> w-36 because the caption wrapped to three lines; that treated the symptom. It takes a `size="compact"` now: stacked, 64px preview, quieter caption. One component with two sizes rather than two components that drift. "1 YOU CHOOSE" READ AS A TALLY OF CHOICES. It counts NOTIFICATIONS — the same thing "17 ALWAYS SENT" counts — but "you choose" is a verb whose object is missing, so after unchecking every channel on the only row the reader is looking at "1 you choose" beside three empty boxes. Now "1 YOU CAN SWITCH OFF": same number, named as a capability, and a direct contrast with the section above it that says these cannot be. Verified in a real browser, both themes. Chrome MCP's screenshot transport is still returning a CDP parameter error, so this pass ran through Playwright with a locally-seeded password. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HcJgdRoQzrRJ8RvEcpUDR5 --- app/components/media-studio/LogoUploader.tsx | 33 +++++++++++++++---- app/components/settings/CredentialsEditor.tsx | 9 ++--- app/components/settings/SignatureCards.tsx | 16 ++++++++- messages/en/components.json | 2 +- 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/app/components/media-studio/LogoUploader.tsx b/app/components/media-studio/LogoUploader.tsx index 93048d3e2..1e5cc1392 100644 --- a/app/components/media-studio/LogoUploader.tsx +++ b/app/components/media-studio/LogoUploader.tsx @@ -4,30 +4,51 @@ export interface LogoUploaderProps { currentUrl: string | null; uploading: boolean; onSelect: (file: File) => void; + /** + * `compact` is for a narrow column — the credential rows, where one of these + * sits beside the label and member-number fields. + * + * The default layout is a WIDE row: a 112px preview plus a text column, with + * 20px of padding. Dropped into the ~144px credential cell that needs 152px + * before the caption gets a pixel, so the preview collapsed to a sliver and + * the button and caption sat off-centre beside it. A second size is the fix; + * a second component would drift. + */ + size?: "default" | "compact"; } /** Media Studio — company logo uploader. Logos keep their original format * (transparent PNG / SVG): NO crop, NO bake. Just upload + fit preview. */ -export function LogoUploader({ currentUrl, uploading, onSelect }: LogoUploaderProps) { +export function LogoUploader({ currentUrl, uploading, onSelect, size = "default" }: LogoUploaderProps) { const inputRef = useRef(null); + const compact = size === "compact"; return ( -
-
+
+
{currentUrl ? ( {m.media_logo_alt()} ) : (
- +
)}
-
+
{ const f = e.target.files?.[0]; if (f) onSelect(f); e.target.value = ""; }} /> -

{m.media_logo_hint()}

+ {/* The caption is a whole-line hint at full size; in the narrow cell it + would wrap to four lines and read as broken layout, so it drops the + letter-spacing and the shouting. */} +

{m.media_logo_hint()}

); diff --git a/app/components/settings/CredentialsEditor.tsx b/app/components/settings/CredentialsEditor.tsx index 8e329e3d7..7300323c4 100644 --- a/app/components/settings/CredentialsEditor.tsx +++ b/app/components/settings/CredentialsEditor.tsx @@ -41,10 +41,11 @@ export function CredentialsEditor({ {credentials.map((c) => (
- {/* Wide enough for the uploader's own caption. At w-24 it wrapped to - three lines and read as a broken layout. */} -
- onUpload(c.id, f)} /> + {/* The uploader's COMPACT size — its default is a wide row that needs + more than this column has, and squeezing it collapsed the preview + to a sliver with the button floating off-centre beside it. */} +
+ onUpload(c.id, f)} />
{/* OPEN by default. `onAdd` creates a blank row, so a collapsed diff --git a/app/components/settings/SignatureCards.tsx b/app/components/settings/SignatureCards.tsx index 92083a755..68449f55c 100644 --- a/app/components/settings/SignatureCards.tsx +++ b/app/components/settings/SignatureCards.tsx @@ -60,7 +60,21 @@ export function EmailSignatureCard({ {previewHtml ? (
{m.settings_profile_signature_preview_label()}
-
+ {/* THE PREVIEW IS DELIBERATELY LIGHT IN BOTH THEMES. + `inspectorSignature()` bakes literal colours into the HTML + (`#0f172a` text, `#e2e8f0` rule) and has to — a mail client has + none of our tokens, and the footer must read correctly in an inbox. + Dropped straight onto a themed card that HTML was near-black on + near-black in dark mode: the one surface whose whole job is showing + what the recipient sees, showing nothing. So the swatch carries the + medium's background rather than the app's. */} +
) : (

{m.settings_profile_signature_empty()}

diff --git a/messages/en/components.json b/messages/en/components.json index 6eca21f3f..643ed2dc8 100644 --- a/messages/en/components.json +++ b/messages/en/components.json @@ -150,7 +150,7 @@ "notif_prefs_always_heading": "Always sent", "notif_prefs_always_reason": "We send these because you need them to get into your account, or because they are your record of something you signed or owe. They cannot be switched off.", "notif_prefs_always_show": "Show what these are", - "notif_prefs_choose_heading": "You choose", + "notif_prefs_choose_heading": "You can switch off", "notif_prefs_saving": "Saving…", "notif_prefs_saved": "Saved", "notif_prefs_bulk_all": "Turn every notification on or off", From bcef1ea96b16282ec8ba8be1b3bb538a5451ce75 Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 1 Aug 2026 08:46:29 +0800 Subject: [PATCH 38/48] perf(credentials): serve badges at the size they are actually drawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing crops or compresses a credential badge on the way in, deliberately: the uploader keeps the original format so a transparent PNG or an SVG survives intact, and the server writes the bytes straight to R2 behind a 2 MB cap and a mime allowlist. Every surface then scales the result to between 28 and 40 CSS pixels. So the allowlist permits JPEG, somebody uploads a photograph, and it is delivered whole to draw a chip the height of a line of text. Email is where that bites: mail clients have no `srcset`, so every recipient downloads the full object on every open. Measured against the photo already in the local bucket: original 60,662 image/jpeg v=email 5,752 image/png -91% v=reportCover 1,418 image/webp -98% v=reportSignature 940 image/webp -98% SERVE-time, not upload-time, and that is the whole reason to do it this way: an upload rule only ever helps the NEXT upload, while the oversized badge already sitting in R2 is the one costing every recipient of every send. The transform path already existed for photos (`serve-photo.ts`); brand-asset simply never used it. EMAIL GETS PNG. Outlook on Windows draws with Word's engine and shows a broken-image box for WebP, and PNG keeps the transparency that makes a badge a badge rather than a white rectangle. A badge that fails to render in an inbox is worse than one that is 30% larger. FAILS OPEN IN EVERY DIRECTION — no variant, no IMAGES binding, an SVG, an unrecognised name, or a transform that throws all serve the original. `v` is a string rather than a zod enum for exactly this: an enum 400s on an unknown value, so during a rolling deploy a client holding older or newer JS would ask for a variant this worker does not know and get a BROKEN IMAGE. Bigger than necessary is a cost; absent is a defect. SVG is left alone — already resolution-independent, and rasterising it would discard the property that makes it the format the uploader recommends. The spec caught a real one on the way: `BADGE_VARIANTS['__proto__']` resolves to `Object.prototype`, which is truthy, so a bare index took the transform branch with `{ width: undefined, format: undefined }`. `Object.hasOwn` now guards it, and with `v` no longer enum-validated at the route that guard is load-bearing rather than theoretical. Verified end to end against the real R2 object, including both degrade paths. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HcJgdRoQzrRJ8RvEcpUDR5 --- app/components/portal/sections/ReportView.tsx | 3 +- .../sections/report/CredentialBadges.tsx | 3 +- scripts/file-size-baseline.json | 4 +- server/api/public/inspector-profile.ts | 68 ++++++++++++-- server/lib/inspector-signature.ts | 9 +- server/lib/mcp/openapi-snapshot.json | 10 +++ server/lib/media/badge-variant.ts | 88 +++++++++++++++++++ tests/unit/credentials/badge-variant.spec.ts | 88 +++++++++++++++++++ .../email/email-signature-integration.spec.ts | 15 ++++ 9 files changed, 276 insertions(+), 12 deletions(-) create mode 100644 server/lib/media/badge-variant.ts create mode 100644 tests/unit/credentials/badge-variant.spec.ts diff --git a/app/components/portal/sections/ReportView.tsx b/app/components/portal/sections/ReportView.tsx index 9fafaa10d..530a2d455 100644 --- a/app/components/portal/sections/ReportView.tsx +++ b/app/components/portal/sections/ReportView.tsx @@ -27,6 +27,7 @@ import { ErrorState } from "~/components/ErrorState"; import { getSectionIcon, itemDrivesSummary } from "~/lib/report-helpers"; import { ReportMediaTile } from "./report/ReportMediaTile"; import { CredentialBadges } from "./report/CredentialBadges"; +import { badgeUrl } from "../../../../server/lib/media/badge-variant"; import { ReportDefectCard } from "./report/ReportDefectCard"; import { PhotoAppendix } from "./report/PhotoAppendix"; import { ReportSignatureBlock } from "./report/ReportSignatureBlock"; @@ -772,7 +773,7 @@ export function ReportView(props: ReportViewProps) { )} {/* ── Signature block ──────────────────────────────────────────── */} - c.imageUrl)?.imageUrl ?? null} /> + c.imageUrl)?.imageUrl ?? null, "reportSignature")} /> {/* ── Verification block ───────────────────────────────────────── */} diff --git a/app/components/portal/sections/report/CredentialBadges.tsx b/app/components/portal/sections/report/CredentialBadges.tsx index 85eef0c45..93bb06a93 100644 --- a/app/components/portal/sections/report/CredentialBadges.tsx +++ b/app/components/portal/sections/report/CredentialBadges.tsx @@ -1,3 +1,4 @@ +import { badgeUrl } from "../../../../../server/lib/media/badge-variant"; // Renders 0-N inspector credentials (Spec B). Image credentials -> (equal // height); text-only credentials -> "label #member". layout comes from the // resolved profile's badgeLayout (Plan 1a): 'strip' = its own wrapping row, @@ -24,7 +25,7 @@ export function CredentialBadges({ return (
{images.map((c, i) => ( - {c.label + {c.label ))} {texts.length > 0 && ( diff --git a/scripts/file-size-baseline.json b/scripts/file-size-baseline.json index 74e5901a8..df56ae6d1 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -3,12 +3,12 @@ "app/routes/inspector-portal.tsx": 1180, "server/services/inspection/inspection-core.service.ts": 1117, "server/services/booking.service.ts": 966, - "server/services/inspection/inspection-report.service.ts": 948, + "server/services/inspection/inspection-report.service.ts": 941, "server/durable-objects/inspection-doc.ts": 912, "server/lib/collab/results-doc.ts": 874, "app/routes/inspections.tsx": 867, "server/api/sms.ts": 843, - "app/components/portal/sections/ReportView.tsx": 806, + "app/components/portal/sections/ReportView.tsx": 807, "app/routes/settings-communication.tsx": 777, "server/services/inspection.service.ts": 757, "server/api/admin/admin-settings.ts": 742, diff --git a/server/api/public/inspector-profile.ts b/server/api/public/inspector-profile.ts index 753aa5e40..fe4b0e534 100644 --- a/server/api/public/inspector-profile.ts +++ b/server/api/public/inspector-profile.ts @@ -9,6 +9,9 @@ import { isServableBrandAsset } from '../../lib/report-style/brand-asset-key'; import { getDrizzle } from '../../lib/route-helpers'; import { r2Get } from '../../lib/r2/objects'; import { getBaseUrl } from '../../lib/url'; +import { imagesBinding } from '../../lib/media/serve-photo'; +import { resolveBadgeVariant, isVectorBadge } from '../../lib/media/badge-variant'; +import { logger } from '../../lib/logger'; const brandRoute = createRoute(withMcpMetadata({ method: 'get', @@ -63,7 +66,20 @@ const brandAssetRoute = createRoute(withMcpMetadata({ path: '/brand-asset', tags: ['public'], summary: 'Public brand asset (tenant logo) bytes', - request: { query: z.object({ key: z.string().describe('R2 object key under the branding/ prefix.') }) }, + request: { query: z.object({ + key: z.string().describe('R2 object key under the branding/ prefix.'), + // A STRING, not an enum, and that is deliberate. An enum 400s on an + // unrecognised value, which during a rolling deploy means a client + // holding older or newer JS asks for a variant this worker does not + // know and gets a BROKEN IMAGE. Unknown degrades to the original + // instead: bigger than it needed to be, which is a cost, rather than + // absent, which is a defect. `resolveBadgeVariant` owns the allowlist. + v: z.string().optional().describe( + 'Render variant: email | reportSignature | reportCover. Badges are drawn at 28-40px ' + + 'but stored at whatever was uploaded, so this serves a size the surface can use. ' + + 'Omitted or unrecognised serves the original, never an error.', + ), + }) }, responses: { 200: { content: { 'image/*': { schema: z.any() } }, description: 'Asset bytes' }, 404: { description: 'Key outside branding/ or object missing' }, @@ -72,6 +88,16 @@ const brandAssetRoute = createRoute(withMcpMetadata({ description: 'Streams a tenant brand asset (logo) from R2. Only keys under the public `branding/` prefix are servable; everything else in the bucket stays scoped to its own routes.', }, { scopes: [], tier: 'extended' })); +/** The stored bytes, unchanged — the answer whenever a transform is not wanted + * or not possible. */ +function brandAssetResponse(obj: R2ObjectBody): Response { + const headers = new Headers(); + headers.set('Content-Type', obj.httpMetadata?.contentType || 'application/octet-stream'); + headers.set('Cache-Control', 'public, max-age=3600'); + if (obj.httpEtag) headers.set('etag', obj.httpEtag); + return new Response(obj.body, { status: 200, headers }); +} + const publicInspectorProfileRoutes = createApiRouter() .openapi(brandRoute, async (c) => { const { tenant } = c.req.valid('param'); @@ -112,7 +138,7 @@ const publicInspectorProfileRoutes = createApiRouter() } }, 200); }) .openapi(brandAssetRoute, async (c) => { - const { key } = c.req.valid('query'); + const { key, v } = c.req.valid('query'); if (!c.env.PHOTOS) return c.notFound(); // Public brand-asset endpoint may ONLY serve branding logos (never arbitrary // R2 objects). New layout: {tenantId}/branding/logo-{uuid}.{ext}; legacy: @@ -121,11 +147,39 @@ const publicInspectorProfileRoutes = createApiRouter() if (!isServableBrandAsset(key)) return c.notFound(); const obj = await r2Get(c.env.PHOTOS, key); if (!obj) return c.notFound(); - const headers = new Headers(); - headers.set('Content-Type', obj.httpMetadata?.contentType || 'application/octet-stream'); - headers.set('Cache-Control', 'public, max-age=3600'); - if (obj.httpEtag) headers.set('etag', obj.httpEtag); - return new Response(obj.body, { status: 200, headers }); + + // Serve-time downscale. This is what reaches the badges ALREADY in R2 — + // an upload-time rule only ever helps the next upload, and the 2 MB + // photograph somebody has already saved is the one costing every + // recipient of every email. + // + // FAILS OPEN IN EVERY DIRECTION: no variant asked for, no IMAGES + // binding, a vector, or a transform that throws — all serve the + // original. A badge that is larger than it needed to be is a cost; a + // badge that does not render is a broken document. + const variant = resolveBadgeVariant(v); + const images = imagesBinding(c.env); + if (variant && images && !isVectorBadge(obj.httpMetadata?.contentType)) { + try { + const out = await images.input(obj.body) + .transform({ width: variant.width }) + .output({ format: variant.format }); + const r = out.response(); + const h = new Headers(r.headers); + // Immutable: the key carries a uuid, so a replaced badge is a + // new key. Longer than the original's hour for that reason. + h.set('Cache-Control', 'public, max-age=31536000, immutable'); + return new Response(r.body, { status: 200, headers: h }); + } catch (err) { + logger.warn('[brand-asset] badge transform failed — serving original', { + key, variant: v, error: String(err), + }); + const orig = await r2Get(c.env.PHOTOS, key); + if (!orig) return c.notFound(); + return brandAssetResponse(orig); + } + } + return brandAssetResponse(obj); }); export default publicInspectorProfileRoutes; diff --git a/server/lib/inspector-signature.ts b/server/lib/inspector-signature.ts index a9c5adf20..306786000 100644 --- a/server/lib/inspector-signature.ts +++ b/server/lib/inspector-signature.ts @@ -16,6 +16,8 @@ * retired. SignatureUser.slug is still accepted but is no longer read. */ +import { badgeUrl } from './media/badge-variant'; + export interface SignatureUser { name?: string | null; email?: string | null; @@ -91,7 +93,12 @@ export function inspectorSignature(user: SignatureUser, host: string): Signature const imgs = creds .filter((c) => c.imageUrl) .map((c) => { - const abs = c.imageUrl!.startsWith('/') ? `https://${host}${c.imageUrl}` : c.imageUrl!; + // The EMAIL variant: PNG (Outlook cannot draw WebP) at twice the + // 28px it is about to be scaled to. Without this the recipient + // downloads whatever was uploaded — up to 2 MB — to render a + // chip the height of a line of text. + const sized = badgeUrl(c.imageUrl, 'email') ?? c.imageUrl!; + const abs = sized.startsWith('/') ? `https://${host}${sized}` : sized; return `${escapeHtml(c.label || 'Credential')}`; }) .join(''); diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json index dc2acedcc..49be3f5af 100644 --- a/server/lib/mcp/openapi-snapshot.json +++ b/server/lib/mcp/openapi-snapshot.json @@ -7620,6 +7620,16 @@ "type": "string", "description": "R2 object key under the branding/ prefix." } + }, + { + "name": "v", + "in": "query", + "required": false, + "description": "Render variant: email | reportSignature | reportCover. Badges are drawn at 28-40px but stored at whatever was uploaded, so this serves a size the surface can use. Omitted or unrecognised serves the original, never an error.", + "schema": { + "type": "string", + "description": "Render variant: email | reportSignature | reportCover. Badges are drawn at 28-40px but stored at whatever was uploaded, so this serves a size the surface can use. Omitted or unrecognised serves the original, never an error." + } } ], "body": null diff --git a/server/lib/media/badge-variant.ts b/server/lib/media/badge-variant.ts new file mode 100644 index 000000000..9a74a1684 --- /dev/null +++ b/server/lib/media/badge-variant.ts @@ -0,0 +1,88 @@ +/** + * Credential badges are rendered TINY and stored as whatever was uploaded. + * + * Nothing crops, resizes or re-encodes a badge on the way in: the uploader keeps + * the original format on purpose (a transparent PNG or an SVG must survive + * intact), and the server writes the bytes straight to R2 behind a 2 MB cap and + * a mime allowlist. Meanwhile every surface that renders one scales it to + * between 28 and 40 CSS pixels. + * + * So a 2 MB photograph — which the allowlist permits, and which people do + * upload — is delivered in full and then drawn at 28px. In an inbox that is 2 MB + * per recipient per send, because mail clients have no `srcset` and fetch + * whatever the `src` names. + * + * Transforming at SERVE time rather than at upload time is what fixes the badges + * ALREADY in R2, which an upload-time rule cannot reach. + */ + +/** The CSS height each surface draws a badge at, and the width we serve for it. */ +export const BADGE_VARIANTS = { + /** `inspector-signature.ts` renders `height:28px`. */ + email: { width: 56, format: 'image/png' as const }, + /** `ReportSignatureBlock` renders `h-8` (32px). */ + reportSignature: { width: 64, format: 'image/webp' as const }, + /** `CredentialBadges` renders `h-10` (40px). */ + reportCover: { width: 80, format: 'image/webp' as const }, +} as const; + +export type BadgeVariant = keyof typeof BADGE_VARIANTS; + +/** + * EMAIL GETS PNG, NOT WEBP, and that is not a rounding error. + * + * Everything else on the web can take WebP. Mail is the one medium where the + * renderer is somebody else's decade-old client: Outlook on Windows draws with + * Word's engine and shows a broken-image box for WebP. A badge that fails to + * render in an inbox is worse than a badge that is 30% larger, so the email + * variant stays PNG — which also keeps transparency, the whole point of a badge. + */ +export function badgeFormat(variant: BadgeVariant): string { + return BADGE_VARIANTS[variant].format; +} + +/** + * Append the variant to a `/api/public/brand-asset` URL. + * + * A no-op for anything that is not one of our brand-asset paths, so a caller + * holding an absolute or already-transformed URL passes it through unchanged. + */ +export function badgeUrl(imageUrl: string | null, variant: BadgeVariant): string | null { + if (!imageUrl || !imageUrl.startsWith('/api/public/brand-asset?')) return imageUrl; + return `${imageUrl}&v=${variant}`; +} + +/** + * Resolve a `?v=` query value to a transform, or null. + * + * Null means "serve the original": an unknown variant, or none asked for. Never + * an error — a badge that fails to load is a worse outcome than a badge that is + * bigger than it needed to be, so every uncertain path here degrades to the + * bytes that were uploaded. + */ +export function resolveBadgeVariant( + raw: string | undefined, +): { width: number; format: string } | null { + if (!raw) return null; + // `Object.hasOwn`, not a bare lookup: `BADGE_VARIANTS['__proto__']` resolves + // to `Object.prototype`, which is TRUTHY — so a bare index would take the + // transform branch with `{ width: undefined, format: undefined }` for a + // caller who asked for `?v=__proto__`. The route's zod enum already bars + // that, but this function is exported and reachable on its own. + if (!Object.hasOwn(BADGE_VARIANTS, raw)) return null; + const v = BADGE_VARIANTS[raw as BadgeVariant]; + return { width: v.width, format: v.format }; +} + +/** + * SVG is left ALONE. + * + * It is already resolution-independent, so there is nothing to gain, and + * rasterising it would throw away the one property that makes it the format the + * uploader recommends. Detected on the stored content type rather than the key, + * because the key's extension is derived from that content type at upload and + * the content type is what the browser will act on. + */ +export function isVectorBadge(contentType: string | null | undefined): boolean { + return (contentType ?? '').includes('svg'); +} diff --git a/tests/unit/credentials/badge-variant.spec.ts b/tests/unit/credentials/badge-variant.spec.ts new file mode 100644 index 000000000..00644a172 --- /dev/null +++ b/tests/unit/credentials/badge-variant.spec.ts @@ -0,0 +1,88 @@ +/** + * Serving a credential badge at the size it is actually drawn. + * + * Nothing crops or compresses a badge on the way in — deliberately, so a + * transparent PNG or an SVG survives intact — and every surface then scales it + * to between 28 and 40 CSS pixels. A 2 MB photograph (which the mime allowlist + * permits, and which people upload) was therefore delivered whole and drawn at + * 28px. In an inbox that is 2 MB per recipient per send, because mail clients + * have no `srcset` and fetch whatever the `src` names. + */ +import { describe, it, expect } from 'vitest'; +import { + BADGE_VARIANTS, badgeFormat, badgeUrl, resolveBadgeVariant, isVectorBadge, +} from '../../../server/lib/media/badge-variant'; + +const BASE = '/api/public/brand-asset?key=t1%2Fcredentials%2Fa%2Flogo.png'; + +describe('badge variants', () => { + it('serves at least twice the CSS height each surface draws', () => { + // The rendered sizes are literals in three different files + // (`height:28px` inline, `h-8`, `h-10`). If one of them grows, this is + // the spec that should make someone revisit the width. + expect(BADGE_VARIANTS.email.width).toBeGreaterThanOrEqual(28 * 2); + expect(BADGE_VARIANTS.reportSignature.width).toBeGreaterThanOrEqual(32 * 2); + expect(BADGE_VARIANTS.reportCover.width).toBeGreaterThanOrEqual(40 * 2); + }); + + it('gives EMAIL png and the web surfaces webp', () => { + // Outlook on Windows draws with Word's engine and shows a broken-image + // box for WebP. A badge that fails to render in an inbox is worse than + // one that is 30% larger — and PNG keeps the transparency that makes it + // a badge rather than a white rectangle. + expect(badgeFormat('email')).toBe('image/png'); + expect(badgeFormat('reportSignature')).toBe('image/webp'); + expect(badgeFormat('reportCover')).toBe('image/webp'); + }); +}); + +describe('badgeUrl', () => { + it('appends the variant to a brand-asset url', () => { + expect(badgeUrl(BASE, 'email')).toBe(`${BASE}&v=email`); + expect(badgeUrl(BASE, 'reportCover')).toBe(`${BASE}&v=reportCover`); + }); + + it('passes through anything that is not a brand-asset path', () => { + // A caller may hold an absolute url (the email signature absolutises + // against the deployment host) or an external one. Rewriting those + // would produce a query the other end does not understand. + expect(badgeUrl('https://cdn.example/logo.png', 'email')).toBe('https://cdn.example/logo.png'); + expect(badgeUrl(null, 'email')).toBeNull(); + }); +}); + +describe('resolveBadgeVariant', () => { + it('resolves the three known variants', () => { + expect(resolveBadgeVariant('email')).toEqual({ width: 56, format: 'image/png' }); + expect(resolveBadgeVariant('reportCover')).toEqual({ width: 80, format: 'image/webp' }); + }); + + it('returns null for absent or unknown, so the original is served', () => { + // Fail OPEN. A badge larger than it needed to be is a cost; a badge + // that does not render is a broken document. + expect(resolveBadgeVariant(undefined)).toBeNull(); + expect(resolveBadgeVariant('')).toBeNull(); + expect(resolveBadgeVariant('enormous')).toBeNull(); + expect(resolveBadgeVariant('__proto__')).toBeNull(); + }); +}); + +describe('isVectorBadge', () => { + it('leaves SVG alone', () => { + // Already resolution-independent, so there is nothing to gain — and + // rasterising it would throw away the property that makes it the format + // the uploader recommends in the first place. + expect(isVectorBadge('image/svg+xml')).toBe(true); + }); + + it('transforms raster formats', () => { + for (const t of ['image/png', 'image/jpeg', 'image/webp']) { + expect(isVectorBadge(t), t).toBe(false); + } + }); + + it('treats a missing content type as raster rather than crashing', () => { + expect(isVectorBadge(null)).toBe(false); + expect(isVectorBadge(undefined)).toBe(false); + }); +}); diff --git a/tests/unit/email/email-signature-integration.spec.ts b/tests/unit/email/email-signature-integration.spec.ts index 2ed4d61e0..1ec289058 100644 --- a/tests/unit/email/email-signature-integration.spec.ts +++ b/tests/unit/email/email-signature-integration.spec.ts @@ -150,6 +150,21 @@ describe('EmailService — credential badges reach the recipient', () => { expect(html).not.toMatch(/]+src="\/api\/public/); }); + it('asks for the EMAIL badge variant, not the stored original', async () => { + await svc.sendReportReady('client@example.com', '1 Main St', 'https://r.example/abc', WITH_CREDENTIALS, HOST); + const html = sent[0]?.html ?? ''; + // Badges are stored at whatever was uploaded — up to 2 MB — and drawn + // here at 28px. Without the variant the recipient downloads the whole + // thing to render a chip the height of a line of text, on every open. + // `&`, not `&` — the whole src goes through escapeHtml, which is the + // correct encoding for an attribute and what every mail client decodes. + // Asserting the raw ampersand here would fail against correct output. + expect(html).toContain('&v=email'); + // PNG, because Outlook draws with Word's engine and shows a broken-image + // box for WebP. The variant name is what carries that decision. + expect(html).toMatch(/]+src="https:\/\/app\.inspectorhub\.io\/api\/public\/brand-asset[^"]*&v=email"/); + }); + it('renders a text-only credential too, so a blocked image never loses it', async () => { await svc.sendReportReady('client@example.com', '1 Main St', 'https://r.example/abc', WITH_CREDENTIALS, HOST); const html = sent[0]?.html ?? ''; From 6ad893a61b4ecf11d343b6b5351c140df4a67b7b Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 1 Aug 2026 10:23:54 +0800 Subject: [PATCH 39/48] fix(settings): the signature card, and two controls that looked broken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things, all from driving the page rather than reading it. THE UPLOAD CONTROLS OPENED NOTHING. Both the credential badge picker and (now) the signature one were a `
- { const f = e.target.files?.[0]; if (f) onSelect(f); e.target.value = ""; }} /> - + {/* The caption is a whole-line hint at full size; in the narrow cell it would wrap to four lines and read as broken layout, so it drops the letter-spacing and the shouting. */} diff --git a/app/components/settings/CredentialsEditor.test.tsx b/app/components/settings/CredentialsEditor.test.tsx new file mode 100644 index 000000000..7cde65518 --- /dev/null +++ b/app/components/settings/CredentialsEditor.test.tsx @@ -0,0 +1,63 @@ +/** + * A refused badge upload has to say so, ON the row it was refused for. + * + * The uploader is a button that opens a file picker; when the server rejects + * what comes back — a 3 MB file against the 2 MB cap is the realistic one — + * nothing about the page changes. To the person who just chose a file that is + * indistinguishable from a button that does nothing, which is exactly how it + * was reported. + * + * A toast cannot fix it either: with three uploaders on screen, a message + * floating at the bottom of the viewport does not say WHICH one refused. + */ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { CredentialsEditor, type EditorCredential } from "./CredentialsEditor"; + +const rows: EditorCredential[] = [ + { id: "c1", label: "Licensed home inspector", memberNumber: "TX-1", imageUrl: null }, + { id: "c2", label: "InterNACHI CPI", memberNumber: null, imageUrl: null }, +]; + +function setup(uploadError: { id: string; message: string } | null) { + return render( + , + ); +} + +describe("CredentialsEditor — upload refusals", () => { + it("says nothing when nothing was refused", () => { + const { container } = setup(null); + expect(container.querySelectorAll('[role="alert"]')).toHaveLength(0); + }); + + it("shows the reason the server gave, not a generic failure", () => { + // The API answers `{ error: { message } }`. Collapsing that to "Save failed" + // tells the reader nothing about the limit they just broke. + setup({ id: "c2", message: "image > 2MB" }); + expect(screen.getByRole("alert").textContent).toBe("image > 2MB"); + }); + + it("puts the message on the ROW that was refused, and only that row", () => { + const { container } = setup({ id: "c2", message: "image > 2MB" }); + const alerts = container.querySelectorAll('[role="alert"]'); + expect(alerts).toHaveLength(1); + // The alert must sit in the SECOND credential's own row — with several + // uploaders on screen, a message that is merely present is not enough. + // Walk up from the alert and assert the row it lands in is c2's. + let row: HTMLElement | null = alerts[0] as HTMLElement; + while (row && !row.querySelector("input:not([type])")) row = row.parentElement; + const labels = [...(row?.querySelectorAll("input:not([type])") ?? [])] + .map((i) => (i as HTMLInputElement).value); + expect(labels).toContain("InterNACHI CPI"); + expect(labels).not.toContain("Licensed home inspector"); + }); +}); diff --git a/app/components/settings/CredentialsEditor.tsx b/app/components/settings/CredentialsEditor.tsx index 7300323c4..27f6cb59a 100644 --- a/app/components/settings/CredentialsEditor.tsx +++ b/app/components/settings/CredentialsEditor.tsx @@ -16,6 +16,7 @@ export interface EditorCredential { export function CredentialsEditor({ credentials, uploadingId, + uploadError, onUpload, onAdd, onUpdate, @@ -23,6 +24,15 @@ export function CredentialsEditor({ }: { credentials: EditorCredential[]; uploadingId: string | null; + /** + * Why the last upload was refused, shown on the row it belongs to. + * + * A per-row failure needs a per-row message. A toast cannot say WHICH of + * three uploaders rejected the file, and a rejected upload otherwise looks + * exactly like a button that does nothing — which is how a 3 MB badge hitting + * the 2 MB limit reads to the person who chose it. + */ + uploadError: { id: string; message: string } | null; onUpload: (id: string, file: File) => void; onAdd: () => void; onUpdate: (id: string, patch: { label?: string; memberNumber?: string }) => void; @@ -44,8 +54,11 @@ export function CredentialsEditor({ {/* The uploader's COMPACT size — its default is a wide row that needs more than this column has, and squeezing it collapsed the preview to a sliver with the button floating off-centre beside it. */} -
+
onUpload(c.id, f)} /> + {uploadError?.id === c.id && ( +

{uploadError.message}

+ )}
{/* OPEN by default. `onAdd` creates a blank row, so a collapsed diff --git a/app/components/settings/SignatureCards.test.tsx b/app/components/settings/SignatureCards.test.tsx index 1498a8776..63a540c24 100644 --- a/app/components/settings/SignatureCards.test.tsx +++ b/app/components/settings/SignatureCards.test.tsx @@ -64,20 +64,72 @@ describe("EmailSignatureCard", () => { }); describe("SavedSignatureCard", () => { - it("offers to add a signature, and nothing that looks like a form submit", () => { - const { container } = renderInRouter(); - const buttons = container.querySelectorAll("button"); - expect(buttons).toHaveLength(1); - // `type="button"` — it opens the pad. Signing is what saves. - expect(buttons[0].getAttribute("type")).toBe("button"); + it("offers BOTH ways to sign, as siblings", () => { + // Drawing and uploading are two routes to one mark, not a primary and a + // fallback: an inspector with a scanned signature has no reason to redraw + // it with a mouse, and one without a scanner cannot upload. + renderInRouter(); + expect(screen.getByRole("button", { name: /draw signature/i })).toBeTruthy(); + expect(screen.getByText(/upload image/i)).toBeTruthy(); + }); + + it("opens the picker through a LABEL, never a scripted click", () => { + // `button` + `inputRef.click()` on a `display:none` input is the pattern + // that silently does nothing when the browser declines it — and a control + // that does not respond is indistinguishable from a broken one. + const { container } = renderInRouter(); + const input = container.querySelector('input[type="file"]'); + expect(input).toBeTruthy(); + expect(input!.closest("label")).toBeTruthy(); + // sr-only, not hidden: still a rendered, focusable control. + expect(input!.className).toContain("sr-only"); + }); + + it("has no form submit — signing is what saves", () => { + const { container } = renderInRouter(); expect(container.querySelectorAll("button[type=submit]")).toHaveLength(0); }); - it("opens the signature pad on click", () => { - renderInRouter(); - fireEvent.click(screen.getByRole("button")); - // The pad replaces the button: there is no state where both are offered, - // which is what would let someone sign and then hit "add" expecting a save. - expect(screen.queryByRole("button", { name: /add|update/i })).toBeNull(); + it("opens the signature pad on Draw", () => { + renderInRouter(); + fireEvent.click(screen.getByRole("button", { name: /draw signature/i })); + // The pad replaces the two actions: there is no state offering both the pad + // and the controls that opened it. + expect(screen.queryByRole("button", { name: /draw signature/i })).toBeNull(); + }); +}); + +/** + * A saved signature has to be VISIBLE. + * + * The card said "Signature saved." and showed nothing — so the one thing a + * reader might want to check, that the mark captured is the one they meant, was + * the one thing the page would not tell them. Short of sending themselves an + * agreement there was no way to find out. + */ +describe("SavedSignatureCard — showing what was saved", () => { + it("renders the saved signature", () => { + renderInRouter(); + const img = screen.getByRole("img"); + expect(img.getAttribute("src")).toBe("data:image/png;base64,AAAA"); + }); + + it("shows the signing line even when empty, and no image", () => { + // The empty state is the same ruled line, an invitation to sign — not a + // grey box announcing an absence. + const { container } = renderInRouter(); + expect(container.querySelectorAll("img")).toHaveLength(0); + expect(screen.getByText(/Nothing signed yet/i)).toBeTruthy(); + }); + + it("drops the empty hint once a signature exists", () => { + renderInRouter(); + expect(screen.queryByText(/Nothing signed yet/i)).toBeNull(); + }); + + it("hides the saved image while the pad is open, so the two never overlap", () => { + renderInRouter(); + fireEvent.click(screen.getByRole("button", { name: /draw signature/i })); + expect(screen.queryByRole("img")).toBeNull(); }); }); diff --git a/app/components/settings/SignatureCards.tsx b/app/components/settings/SignatureCards.tsx index 68449f55c..3518ffad4 100644 --- a/app/components/settings/SignatureCards.tsx +++ b/app/components/settings/SignatureCards.tsx @@ -84,19 +84,55 @@ export function EmailSignatureCard({ } /** - * The drawn signature used on reports and agreements. + * THE signature — the mark applied to agreements and published reports. * - * Its feedback stays INLINE rather than becoming a toast: the pad is a modal - * act the reader is looking straight at when it resolves, so the confirmation - * belongs where their attention already is. (The toast exists for the saves - * that happen without ceremony — a blurred field, a flipped checkbox.) + * One signature, two equal ways to produce it. Drawing and uploading are + * siblings, not a primary and a fallback: an inspector with a scanned signature + * on file has no reason to redraw it with a mouse, and one without a scanner has + * no way to upload. So the two actions carry the same weight and sit together + * under the mark they replace. + * + * The swatch is a signature LINE, not an image frame: a white field with a + * hairline baseline, the way a printed form presents the space you sign. Empty, + * it is the same line — an invitation to sign rather than a grey box announcing + * that there is nothing there. + * + * Everything is left-aligned, including the actions. The card's content is + * left-aligned, and a centred control under left-aligned content reads as an + * accident rather than a decision. */ -export function SavedSignatureCard() { +export function SavedSignatureCard({ savedSignature }: { savedSignature: string | null }) { const fetcher = useFetcher(); const [showPad, setShowPad] = useState(false); + const [uploadError, setUploadError] = useState(null); const isOurs = fetcher.data?.intent === "save-signature"; - const saved = isOurs && fetcher.data?.success === true; - const error = isOurs && typeof fetcher.data?.error === "string" ? fetcher.data.error : null; + + /** + * WHERE THE ANSWER GOES, and why it is two different places. + * + * The SAVE result is a toast. A full-width banner between the heading and the + * mark pushed the card open, stayed after the moment had passed, and said + * "Signature saved." directly above a signature that was visibly already + * there — a receipt for something the reader could see. The mark changing IS + * the confirmation; the toast is only there for the case where the new one + * looks like the old one. + * + * A FILE the reader just chose and we refused is different: no request was + * made, the fault is in their hand, and the message has to sit next to the + * control they will use again. That one stays inline. + */ + useNotificationSaveToast({ + data: isOurs ? fetcher.data : null, + failed: isOurs && fetcher.data?.success === false, + error: isOurs && typeof fetcher.data?.error === "string" ? fetcher.data.error : null, + }); + + const submit = (dataUri: string) => { + const fd = new FormData(); + fd.append("intent", "save-signature"); + fd.append("signatureBase64", dataUri); + fetcher.submit(fd, { method: "post" }); + }; return (
@@ -105,38 +141,124 @@ export function SavedSignatureCard() {

{m.settings_profile_saved_signature_subtitle()}

- {saved && ( -
- {m.settings_profile_signature_saved_flash()} -
- )} - {error && ( -
- {error} -
- )} {showPad ? ( setShowPad(false)} - onSubmit={(dataUri) => { - const fd = new FormData(); - fd.append("intent", "save-signature"); - fd.append("signatureBase64", dataUri); - fetcher.submit(fd, { method: "post" }); - setShowPad(false); - }} + onSubmit={(dataUri) => { submit(dataUri); setShowPad(false); }} /> ) : ( - +
+ {/* The signing line. White in both themes because that is the paper + the mark is applied to — a signature previewed on a dark card is + not the signature anyone receives. */} +
+ {/* The rule is a literal slate hairline, not a token: it lives on a + fixed-white field, so a theme-aware border would be invisible in + one of the two themes — which is exactly what it was. */} +
+ {savedSignature && ( + {m.settings_profile_saved_signature_alt()} + )} +
+
+ + {!savedSignature && ( +

{m.settings_profile_signature_empty_hint()}

+ )} + + {/* Two ways to the same thing, so neither outranks the other. */} +
+ + +
+ + {uploadError && ( +

{uploadError}

+ )} +
)}
); } + +/** + * Read an uploaded signature into a data URI, DOWNSCALED. + * + * The column is TEXT and its value is read on every report render and every + * agreement, so a phone photo pasted in whole would be carried around forever + * for a mark drawn at a couple of hundred pixels. Raster images are redrawn + * through a canvas at signature scale; SVG passes through untouched, being + * resolution-independent already. + * + * Returns an error message, or null on success. + */ +async function readSignatureFile( + file: File, + onReady: (dataUri: string) => void, +): Promise { + const ALLOWED = ["image/png", "image/jpeg", "image/webp", "image/svg+xml"]; + if (!ALLOWED.includes(file.type)) return m.settings_profile_signature_upload_bad_type(); + if (file.size > 2_000_000) return m.settings_profile_signature_upload_too_big(); + + const dataUri = await new Promise((resolve) => { + const r = new FileReader(); + r.onload = () => resolve(typeof r.result === "string" ? r.result : null); + r.onerror = () => resolve(null); + r.readAsDataURL(file); + }); + if (!dataUri) return m.settings_profile_signature_upload_unreadable(); + + if (file.type === "image/svg+xml") { onReady(dataUri); return null; } + + const scaled = await new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + const MAX_H = 200, MAX_W = 600; + const ratio = Math.min(MAX_W / img.width, MAX_H / img.height, 1); + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(img.width * ratio)); + canvas.height = Math.max(1, Math.round(img.height * ratio)); + const ctx = canvas.getContext("2d"); + if (!ctx) { resolve(null); return; } + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + // PNG, so a signature on transparency stays on transparency. + resolve(canvas.toDataURL("image/png")); + }; + img.onerror = () => resolve(null); + img.src = dataUri; + }); + if (!scaled) return m.settings_profile_signature_upload_unreadable(); + onReady(scaled); + return null; +} diff --git a/app/routes/settings-profile.tsx b/app/routes/settings-profile.tsx index 169000d00..df1b104d5 100644 --- a/app/routes/settings-profile.tsx +++ b/app/routes/settings-profile.tsx @@ -33,6 +33,7 @@ interface Profile { photoUrl?: string | null; signatureEnabled?: boolean; signaturePreviewHtml?: string; + savedSignature?: string | null; timezone?: string | null; locale?: string | null; } @@ -79,6 +80,22 @@ export function signatureEnabledFromForm(fd: FormData): boolean | undefined { return vals[vals.length - 1] === "true"; } +/** + * The reason the API refused, or a generic fallback. + * + * The envelope is `{ success: false, error: { code, message } }` — the message + * is NESTED. Reading `err.message` off the top level (which several call sites + * did) always misses, so every refusal collapsed to "Save failed": a 3 MB badge + * upload told the reader nothing about the 2 MB limit it had just broken, which + * is indistinguishable from the button doing nothing at all. + */ +async function apiErrorMessage(res: { json: () => Promise }): Promise { + const body = await res.json().catch(() => ({})); + const nested = (body as { error?: { message?: string } })?.error?.message; + const flat = (body as { message?: string })?.message; + return nested || flat || m.settings_error_save_failed(); +} + export async function action({ request, context }: Route.ActionArgs) { const token = await requireToken(context, request); const api = createApi(context, { token }); @@ -125,8 +142,7 @@ export async function action({ request, context }: Route.ActionArgs) { // hono/client form: keys must match the API schema field names const res = await api.profile.photo.$post({ form: { photo } } as Parameters[0]); if (!res.ok) { - const err = await res.json().catch(() => ({})); - return { success: false, error: (err as Record)?.message || m.settings_profile_error_upload_failed(), intent }; + return { success: false, error: await apiErrorMessage(res), intent }; } return { success: true, error: null, intent }; } @@ -143,8 +159,7 @@ export async function action({ request, context }: Route.ActionArgs) { // `webSocket` field. Only `ok` and `json()` are read here. const credentialResult = async (res: { ok: boolean; json: () => Promise }, i: string) => { if (res.ok) return { success: true, error: null, intent: i }; - const err = await res.json().catch(() => ({})); - return { success: false, error: (err as Record)?.message || m.settings_error_save_failed(), intent: i }; + return { success: false, error: await apiErrorMessage(res), intent: i }; }; if (intent === "credential-add") { return credentialResult(await api.credentials.index.$post({ json: { label: "" } }), intent); @@ -279,6 +294,11 @@ export default function SettingsProfilePage() { error: credImageFetcher.data?.error ?? null, }); const [uploadingCredId, setUploadingCredId] = useState(null); + // The row the last upload was for, kept so a refusal can be shown ON it. + const [lastUploadCredId, setLastUploadCredId] = useState(null); + const credUploadError = lastUploadCredId && credImageFetcher.data?.success === false + ? { id: lastUploadCredId, message: credImageFetcher.data.error ?? m.settings_error_save_failed() } + : null; useEffect(() => { if (credImageFetcher.state === "idle") setUploadingCredId(null); }, [credImageFetcher.state]); @@ -296,6 +316,7 @@ export default function SettingsProfilePage() { const onCredDelete = (id: string) => credFetcher.submit({ intent: "credential-delete", id }, { method: "post" }); const onCredUpload = (id: string, file: File) => { setUploadingCredId(id); + setLastUploadCredId(id); const f = new FormData(); f.append("intent", "credential-image"); f.append("id", id); @@ -492,11 +513,12 @@ export default function SettingsProfilePage() { previewHtml={profile.signaturePreviewHtml ?? null} /> - +