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/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 ( ; @@ -178,8 +181,11 @@ export function EditorHeader({ )} - {/* Center zone: report search + version history [tools] */} -
+ {/* Center zone: report search + version history [tools]. Both hide together + at the same breakpoint; only version history reappears in the More menu. + Search does not: typing into a popover is worse than not offering it, and + an inspector on a tablet navigates by section rail, not by search. */} +
{/* Search */} + {/* Report settings — a labelled button, not a bare gear. IA-87 ③ / IA-88 ⑤: this was the only unlabelled icon in a row of text @@ -262,51 +268,33 @@ export function EditorHeader({ canUpdateSource={canUpdateSourceTemplate} /> - {/* Preview full report — opens the whole report (all sections) in a new tab. - Owner preview works on drafts (tokenless via the report-view loader). */} - {tenantSlug && ( - - )} - - {/* Preview PDF — opens the real server-rendered PDF deliverable (the exact - client deliverable) in a new tab. Owner on-demand render works pre-publish - on drafts via the owner/JWT-authed /api/inspections/:id/pdf endpoint. */} - + onPreviewPdf={() => pdf.exportPdf(`/api/inspections/${state.inspection.id}/pdf?type=full`, { mode: "view", filename: `report-${state.inspection.id}.pdf` })} + pdfBusy={pdf.busy} + pdfError={pdf.error} + /* Inside a menu already labelled "Preview", the rows name destinations, + not actions — "Web report" / "PDF", not "Web report" / "Preview PDF". + The busy and cooldown states replace the label outright, so the short + form loses nothing. */ + pdfLabel={pdfActionLabel(pdf, m.editor_header_preview_pdf_short())} + /> {/* Sign now button */} )} + {/* More [COMPOSE tier overflow] — holds exactly what this width dropped. + Sits immediately left of Publish so the controls that vanished from the + row are one tap from where they used to be. Hides itself at xl, where + everything it holds is inline again. */} + setSignModalOpen(true)} + onFinishFieldwork={ + (state.inspection.status as string) !== "completed" ? handleFinishFieldwork : null + } + finishingFieldwork={finishingFieldwork} + onOpenVersionHistory={collabEditing && onOpenVersionHistory ? onOpenVersionHistory : null} + /* Full width in the menu: at its intrinsic size the four segments sit in + a short pill against a much wider panel, reading as a stray chip rather + than a control. */ + themeControl={} + /> + {/* Publish button */} + + setOpen(false)} anchorRef={anchorRef} align="right"> + {/* Wide enough that the four-segment theme control in the last row + breathes; the action rows above are far shorter than this. */} +
    +
  • + } + onClick={() => run(onSign)} + > + {m.editor_header_sign()} + +
  • + {onFinishFieldwork && ( +
  • + } + disabled={finishingFieldwork} + onClick={() => run(onFinishFieldwork)} + > + {finishingFieldwork ? m.editor_finish_fieldwork_pending() : m.editor_finish_fieldwork()} + +
  • + )} + {onOpenVersionHistory && ( +
  • + +
  • + )} +
  • +
    {m.nav_theme_label()}
    + {themeControl} +
  • +
+
+ + ); +} diff --git a/app/components/editor/MobileDrawerTriggers.tsx b/app/components/editor/MobileDrawerTriggers.tsx index 17fbdf995..d13c280bf 100644 --- a/app/components/editor/MobileDrawerTriggers.tsx +++ b/app/components/editor/MobileDrawerTriggers.tsx @@ -1,6 +1,12 @@ import { m } from "~/paraglide/messages"; -export type MobileDrawerId = 'sections' | 'items' | 'preview' | 'theme'; +/** + * `actions` has no trigger in this nav — it opens from the app bar's own + * "More actions" button, which is where a phone user already looks for the + * things that end a job (publish, sign, preview). Adding a fifth tab here + * would put the terminal action in the same row as the navigation. + */ +export type MobileDrawerId = 'sections' | 'items' | 'preview' | 'theme' | 'actions'; export interface MobileDrawerTriggersProps { onOpen: (id: MobileDrawerId) => void; @@ -17,7 +23,7 @@ export function MobileDrawerTriggers({ onOpen }: MobileDrawerTriggersProps) { {([ { id: 'sections', label: m.editor_mobile_drawer_sections(), icon: '☰' }, { id: 'items', label: m.editor_mobile_drawer_items(), icon: '≣' }, - { id: 'preview', label: m.editor_header_preview(), icon: '👁' }, + { id: 'preview', label: m.editor_route_drawer_preview(), icon: '👁' }, { id: 'theme', label: m.nav_theme_label(), icon: '◐' }, ] as const).map(t => ( + + {onPreviewReport && ( + + )} + + {onFinishFieldwork && ( + + )} +
+ + ); +} diff --git a/app/components/editor/PreviewMenu.tsx b/app/components/editor/PreviewMenu.tsx new file mode 100644 index 000000000..58f5f0991 --- /dev/null +++ b/app/components/editor/PreviewMenu.tsx @@ -0,0 +1,94 @@ +import { useRef, useState } from "react"; +import { Button, MenuItem, Popover } from "@core/shared-ui"; +import { m } from "~/paraglide/messages"; + +export interface PreviewMenuProps { + /** Open the full web report in a new tab. Null when the tenant slug is unknown. */ + onPreviewReport: (() => void) | null; + /** Render and open the server-side PDF — the exact client deliverable. */ + onPreviewPdf: () => void; + /** A PDF render is in flight. */ + pdfBusy: boolean; + /** Last PDF render error, surfaced on the trigger's tooltip. */ + pdfError: string | null; + /** PDF item label, already decorated with the hook's busy/error state. */ + pdfLabel: string; +} + +/** + * "Preview" — the report's two fidelities behind one header control. + * + * These were two side-by-side buttons hidden at `2xl` (web report) and `xl` + * (PDF), which put the whole 768-1279px band — iPad landscape included — in a + * state where Publish was reachable but neither preview was. A header is a + * commit bar, and the rehearsal must never be less reachable than the + * performance, so the fix is not to widen the breakpoints but to spend one + * control where two were spent: web report and PDF are one intent at two + * fidelities, never two decisions. + */ +export function PreviewMenu({ + onPreviewReport, + onPreviewPdf, + pdfBusy, + pdfError, + pdfLabel, +}: PreviewMenuProps) { + const [open, setOpen] = useState(false); + const anchorRef = useRef(null); + + const run = (fn: () => void) => { + setOpen(false); + fn(); + }; + + return ( + <> + + + setOpen(false)} anchorRef={anchorRef} align="right"> +
    + {onPreviewReport && ( +
  • + run(onPreviewReport)} + title={m.editor_header_preview_full_title()} + > + {m.editor_header_preview_report()} + +
  • + )} +
  • + run(onPreviewPdf)} + disabled={pdfBusy} + title={pdfError ?? m.editor_header_preview_pdf_title()} + > + {pdfLabel} + +
  • +
+
+ + ); +} diff --git a/app/components/editor/header-visibility.test.ts b/app/components/editor/header-visibility.test.ts new file mode 100644 index 000000000..239baf598 --- /dev/null +++ b/app/components/editor/header-visibility.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { HEADER_OVERFLOW, pivotBreakpoint } from './header-visibility'; + +// `import.meta.url` is not a file: URL under the web vitest config (the module +// graph is served by Vite), so resolve from the repo root instead. +const dir = join(process.cwd(), 'app', 'components', 'editor'); +const read = (f: string) => readFileSync(join(dir, f), 'utf8'); +const HEADER_SRC = read('EditorHeader.tsx'); +const OVERFLOW_SRC = read('HeaderOverflowMenu.tsx'); +const PREVIEW_SRC = read('PreviewMenu.tsx'); + +describe('editor header visibility table', () => { + it('pairs every COMPOSE-tier control on one breakpoint', () => { + for (const [key, pair] of Object.entries(HEADER_OVERFLOW)) { + expect(pivotBreakpoint(pair), `${key} is not an inverse pair`).not.toBeNull(); + } + }); + + it('rejects a pair whose two sides pivot on different breakpoints', () => { + expect(pivotBreakpoint({ inline: 'hidden xl:inline-flex', row: 'lg:hidden' })).toBeNull(); + }); + + it('rejects an "inline" class that is not hidden by default', () => { + // Without a bare `hidden`, the button shows at every width and the overflow + // row duplicates it below the breakpoint. + expect(pivotBreakpoint({ inline: 'xl:inline-flex', row: 'xl:hidden' })).toBeNull(); + }); + + /** + * The table is only load-bearing while both sides actually read it. A + * hand-written `className="xl:hidden"` next to it would look identical and + * drift on the next breakpoint change, which is the whole failure mode this + * file exists to remove. + */ + it('is the source both the header and the overflow menu read from', () => { + for (const src of [HEADER_SRC, OVERFLOW_SRC]) { + expect(src).toContain('HEADER_OVERFLOW'); + } + for (const key of Object.keys(HEADER_OVERFLOW)) { + expect(HEADER_SRC, `header does not consume HEADER_OVERFLOW.${key}`) + .toContain(`HEADER_OVERFLOW.${key}.inline`); + expect(OVERFLOW_SRC, `overflow menu does not consume HEADER_OVERFLOW.${key}`) + .toContain(`HEADER_OVERFLOW.${key}.row`); + } + }); + + it('hides the overflow trigger where every control it holds is inline again', () => { + // Sign, version history and theme all return at xl, so at xl the menu is + // empty and its trigger must not render. + const bps = new Set(Object.values(HEADER_OVERFLOW).map(pivotBreakpoint)); + expect(bps.has('xl')).toBe(true); + expect(OVERFLOW_SRC).toContain('className="xl:hidden"'); + }); +}); + +describe('Preview is never width-gated', () => { + /** + * The regression this layout exists to prevent: Publish never hides, so + * neither may the control that shows you what you are about to publish. + * Before this, the web report was 2xl-only and the PDF xl-only, which left + * the entire 768-1279px band — iPad landscape included — able to publish a + * report it had no way to look at first. + */ + it('renders no responsive hide class on the Preview trigger', () => { + const trigger = PREVIEW_SRC.slice(0, PREVIEW_SRC.indexOf('')); + expect(trigger).not.toMatch(/\b(?:sm|md|lg|xl|2xl):(?:inline-flex|flex|hidden)\b/); + }); + + it('leaves no 2xl-gated action button in the header', () => { + expect(HEADER_SRC).not.toContain('2xl:inline-flex'); + }); + + it('reaches both fidelities from the one control', () => { + expect(PREVIEW_SRC).toContain('preview-report-btn'); + expect(PREVIEW_SRC).toContain('preview-pdf-btn'); + }); +}); diff --git a/app/components/editor/header-visibility.ts b/app/components/editor/header-visibility.ts new file mode 100644 index 000000000..986f94502 --- /dev/null +++ b/app/components/editor/header-visibility.ts @@ -0,0 +1,45 @@ +/** + * Editor-header visibility table — the single source for which COMPOSE-tier + * control lives in the header row and which lives in the "More" overflow, at + * every width. + * + * The header and the overflow menu are two lists that must be exact + * complements: a control visible in both is duplicated, and a control in + * neither is unreachable — which is precisely how Preview came to be + * `2xl`-gated while Publish stayed always-on, leaving the whole 768-1279px + * band (iPad landscape included) able to publish a report it could not look + * at first. + * + * Rather than restate the pairing in a comment on each side, both sides import + * these strings, so drift is not something anyone has to remember. `inline` is + * the header button's class; `row` is the overflow row's. `header-visibility` + * asserts they stay inverses. + * + * Tailwind scans .ts sources, so these literals are picked up for generation. + */ +export const HEADER_OVERFLOW = { + /** Manual signature — the Sign modal. */ + sign: { inline: 'hidden xl:inline-flex', row: 'xl:hidden' }, + /** Advisory order-lifecycle move; the publish modal offers it too. */ + finishFieldwork: { inline: 'hidden lg:inline-flex', row: 'lg:hidden' }, + /** #181 — collab-only version history. */ + versionHistory: { inline: 'hidden xl:flex', row: 'xl:hidden' }, + /** auto / light / dark / field theme control. */ + theme: { inline: 'hidden xl:flex', row: 'xl:hidden' }, +} as const; + +/** + * The breakpoint a class pair pivots on, or null when the pair is malformed + * (no breakpoint, or the two sides name different ones). Exported for the + * conformance test rather than inlined there, so the parse rule and the table + * ship together. + */ +export function pivotBreakpoint(pair: { inline: string; row: string }): string | null { + const inlineBp = /(?:^|\s)(sm|md|lg|xl|2xl):(?:inline-flex|flex|block|inline)(?:\s|$)/.exec(pair.inline)?.[1]; + const rowBp = /(?:^|\s)(sm|md|lg|xl|2xl):hidden(?:\s|$)/.exec(pair.row)?.[1]; + if (!inlineBp || !rowBp || inlineBp !== rowBp) return null; + // The header button must also START hidden, or it shows at every width and + // the overflow row duplicates it below the breakpoint. + if (!/(?:^|\s)hidden(?:\s|$)/.test(pair.inline)) return null; + return inlineBp; +} diff --git a/app/components/media-studio/AvatarCropper.tsx b/app/components/media-studio/AvatarCropper.tsx index 673f0ad21..def78690e 100644 --- a/app/components/media-studio/AvatarCropper.tsx +++ b/app/components/media-studio/AvatarCropper.tsx @@ -1,42 +1,36 @@ -import { useState, useCallback } from "react"; -import Cropper from "react-easy-crop"; -import { bakeCrop, type PixelCrop } from "./cropImage"; +import { PhotoCropper } from "./PhotoCropper"; import { m } from "~/paraglide/messages"; const AVATAR_EDGE = 512; + export interface AvatarCropperProps { sourceUrl: string; onCancel: () => void; onSave: (blob: Blob) => void; } + +/** + * Thin wrapper over PhotoCropper that fixes the avatar's shape and size — the + * same relationship CoverCropper has to it. + * + * It used to be a second copy of the cropper: its own Cropper, its own zoom + * slider, its own bake. That copy is why rotation, once added, would have + * reached inspection photos and cover images and not a profile photo — and a + * profile photo, taken by whoever was standing there with a phone, is the one + * most likely to arrive sideways. + */ export function AvatarCropper({ sourceUrl, onCancel, onSave }: AvatarCropperProps) { - const [crop, setCrop] = useState({ x: 0, y: 0 }); - const [zoom, setZoom] = useState(1); - const [pixels, setPixels] = useState(null); - const [busy, setBusy] = useState(false); - const onComplete = useCallback((_a: unknown, p: PixelCrop) => setPixels(p), []); - async function handleSave() { - if (!pixels) return; - setBusy(true); - try { onSave(await bakeCrop(sourceUrl, pixels, AVATAR_EDGE)); } finally { setBusy(false); } - } return ( - // ds-allow: full-bleed media studio chrome, intentional fixed-dark backdrop -
-
- -
-
-
- {m.media_common_zoom()} - setZoom(Number(e.target.value))} className="flex-1 accent-ih-primary" aria-label={m.media_common_zoom()} /> -
-
- - -
-
-
+ onSave(blob)} + /> ); } diff --git a/app/components/media-studio/LogoUploader.tsx b/app/components/media-studio/LogoUploader.tsx index 93048d3e2..76db063d3 100644 --- a/app/components/media-studio/LogoUploader.tsx +++ b/app/components/media-studio/LogoUploader.tsx @@ -4,30 +4,69 @@ 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/media-studio/PhotoCropper.tsx b/app/components/media-studio/PhotoCropper.tsx index 8202e3d6a..647b3cd19 100644 --- a/app/components/media-studio/PhotoCropper.tsx +++ b/app/components/media-studio/PhotoCropper.tsx @@ -1,9 +1,14 @@ import { useState, useCallback } from "react"; import Cropper from "react-easy-crop"; -import { bakeCrop, type PixelCrop } from "./cropImage"; +import { bakeCrop, normalizeRotation, type BakeFormat, type PixelCrop } from "./cropImage"; import { m } from "~/paraglide/messages"; -const RATIOS: Record = { "3:2": 3 / 2, "16:9": 16 / 9, "1.91:1": 1.91, "4:3": 4 / 3 }; +const RATIOS: Record = { + "3:2": 3 / 2, "16:9": 16 / 9, "1.91:1": 1.91, "4:3": 4 / 3, + // A square, for the round avatar crop and for the many association badges + // that are round seals. A signature strip is far wider than any photo ratio. + "1:1": 1, "3:1": 3, +}; export interface PhotoCrop { aspect: string; // 'free' or a preset key @@ -23,6 +28,17 @@ export interface PhotoCropperProps { title?: string; /** Save button label. */ saveLabel?: string; + /** Round mask — an avatar, or a round association seal. */ + cropShape?: "rect" | "round"; + /** + * Output encoding. PNG for anything composited onto a document (a signature, + * a badge): JPEG has no alpha channel, so it fills the transparency those are + * cut out against with white, and the mark lands on the report cover as a + * white rectangle. Photographs stay JPEG — a third of the size, nothing lost. + */ + outputFormat?: BakeFormat; + /** Long-edge clamp on the bake. Small for a badge, large for a photo. */ + maxLongEdge?: number; onCancel: () => void; onSave: (blob: Blob, crop: PhotoCrop) => void; } @@ -36,6 +52,9 @@ export function PhotoCropper({ initialAspect, title = m.media_cropper_title(), saveLabel = m.media_cropper_save(), + cropShape = "rect", + outputFormat = "image/jpeg", + maxLongEdge, onCancel, onSave, }: PhotoCropperProps) { @@ -44,8 +63,18 @@ export function PhotoCropper({ const [portrait, setPortrait] = useState(false); const [crop, setCrop] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); + /** + * Quarter turns, because that is the shape of the problem. + * + * Every source that arrives sideways arrives sideways by a quarter turn — a + * phone held the other way, a page fed into a scanner rotated. Two buttons + * answer that exactly and read at a glance; a free-angle slider answers a + * question nobody asked and makes the common case a matter of aim. + */ + const [rotation, setRotation] = useState(0); const [pixels, setPixels] = useState(null); const [busy, setBusy] = useState(false); + const [bakeError, setBakeError] = useState(null); // Plan 4 Q5 — the source image's long edge (natural px). Threaded into bakeCrop // so the crop rect is rescaled when the CDN returns a bounded variant. const [sourceLongEdge, setSourceLongEdge] = useState(undefined); @@ -61,11 +90,20 @@ export function PhotoCropper({ async function handleSave() { if (!pixels) return; setBusy(true); + setBakeError(null); try { // Plan 4 Q5 — thread the source long edge so bakeCrop rescales the crop // rect when the CDN returns a bounded (<=4096) variant of a huge original. - const blob = await bakeCrop(sourceUrl, pixels, undefined, sourceLongEdge); + const blob = await bakeCrop(sourceUrl, pixels, { + maxLongEdge, sourceLongEdge, rotation, format: outputFormat, + }); onSave(blob, { aspect: aspectKey, orientation: portrait ? "portrait" : "landscape", pixels }); + } catch { + // A bake that throws used to leave the dialog sitting there exactly as it + // was: the reader clicks Save, nothing closes, nothing saves, nothing + // says why. Whatever the cause — an unreadable source, no canvas — the + // one thing that must not happen is the button appearing inert. + setBakeError(m.media_cropper_bake_failed()); } finally { setBusy(false); } } @@ -73,12 +111,17 @@ export function PhotoCropper({ // ds-allow: full-bleed media studio chrome, intentional fixed-dark backdrop
-
-
+ {/* Hidden when there is only ever one shape to crop to (the avatar's + square). A row of one button that cannot be un-chosen, next to an + orientation toggle that does nothing to a square, is a control that + asks a question with a single answer. */} +
1 ? "flex" : "hidden"}`}> {options.map((a) => ( +
+ {bakeError && ( +

{bakeError}

+ )}
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.test.tsx b/app/components/notifications/NotificationPreferences.test.tsx new file mode 100644 index 000000000..4b9ab0f96 --- /dev/null +++ b/app/components/notifications/NotificationPreferences.test.tsx @@ -0,0 +1,120 @@ +/** + * The bulk controls, and the one rule that is easy to get backwards. + * + * 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, fireEvent } from "@testing-library/react"; +import { NotificationPreferences, type ChoiceRow } from "./NotificationPreferences"; + +const rows: ChoiceRow[] = [ + { 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]> = {}) { + const onBulk = vi.fn(); + const onChange = vi.fn(); + const utils = render( + , + ); + return { ...utils, onBulk, 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("bulk controls", () => { + 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(); + 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", () => { + // 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("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("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("scopes the corner control to the whole grid", () => { + const c = setup(); + fireEvent.click(byLabel(c, "Turn every")); + 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]] }); + expect(boxes(c).filter((b) => (b.getAttribute("aria-label") ?? "").startsWith("Turn"))).toHaveLength(0); + }); + + 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("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(/keeps applying/i)).toBeTruthy(); + }); +}); diff --git a/app/components/notifications/NotificationPreferences.tsx b/app/components/notifications/NotificationPreferences.tsx new file mode 100644 index 000000000..588739a98 --- /dev/null +++ b/app/components/notifications/NotificationPreferences.tsx @@ -0,0 +1,307 @@ +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. + */ + +type ChannelState = "on" | "off"; +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; + /** + * 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"; + /** + * 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; + /** + * 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. */ +type BulkState = "all" | "none" | "some"; + +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; + cells.push(r.channels[c.id]); + } + } + 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 }> = [ + { 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, lockedReason, +}: { + row: ChoiceRow; + channel: ChannelId; + channelLabel: string; + onChange: NotificationPreferencesProps["onChange"]; + busy: boolean; + lockedReason?: string | undefined; +}) { + 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} + onChange(row.id, channel, e.currentTarget.checked)} + /> +
+ ); +} + +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 + // is noise. + const bulk = onBulk && youChoose.length > 1 ? onBulk : undefined; + 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()} +

+ {/* 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() : ""} + +
+ + {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. +
+
+ + {bulk && bulkStateOf(youChoose, {}) && ( + <> + bulk(enabled, {})} + /> + + {m.notif_prefs_bulk_all_short()} + + + )} + + {CHANNELS.map((c) => ( + + + {c.label()} + + {bulk && !lockedChannels[c.id] && bulkStateOf(youChoose, { channel: c.id }) && ( + bulk(enabled, { channel: c.id })} + /> + )} + + ))} +
+
+ {youChoose.map((row) => ( +
+ + {bulk && bulkStateOf(youChoose, { classId: row.id }) && ( + bulk(enabled, { classId: row.id })} + /> + )} + {row.label} + + {CHANNELS.map((c) => ( + + ))} +
+ ))} +
+ {/* 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/components/notifications/NotificationSettings.tsx b/app/components/notifications/NotificationSettings.tsx new file mode 100644 index 000000000..e9dcf1425 --- /dev/null +++ b/app/components/notifications/NotificationSettings.tsx @@ -0,0 +1,121 @@ +import { useFetcher } from "react-router"; +import { + NotificationPreferences, + type AlwaysSentItem, + 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 } : {}), + })} + /> + )} +
+ ); +} diff --git a/app/components/notifications/SmsConsentBlock.test.tsx b/app/components/notifications/SmsConsentBlock.test.tsx new file mode 100644 index 000000000..579cb0bf6 --- /dev/null +++ b/app/components/notifications/SmsConsentBlock.test.tsx @@ -0,0 +1,106 @@ +/** + * 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 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, mode: "express", +}; + +function setup(consent: Partial = {}, manageHref?: string) { + const onStop = vi.fn(); + const onGrant = vi.fn(); + const utils = render( + , + ); + return { ...utils, onStop, onGrant }; +} + +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 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(/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 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. + 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..896283c1f --- /dev/null +++ b/app/components/notifications/SmsConsentBlock.tsx @@ -0,0 +1,166 @@ +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"; + +/** + * 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. + */ + +type SmsConsentState = "granted" | "implied" | "revoked" | "none"; + +export interface SmsConsent { + phone: string | null; + 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 = { + 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, 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. + * + * `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"; + const [ack, setAck] = useState(false); + 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 ( +
+

+ {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()}

+ )} + + {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. +
+
+ + {m.notif_prefs_sms_disclosure_show()} + +
+ +
+
+ +
+ )} + +
+ {canGrant && ( + + )} + {on && ( + + )} + {manageHref && ( + + {m.notif_prefs_sms_manage()} → + + )} + {m.notif_prefs_sms_stop_hint()} +
+
+ ); +} diff --git a/app/components/notifications/SmsDisclosure.tsx b/app/components/notifications/SmsDisclosure.tsx new file mode 100644 index 000000000..4c594c1ac --- /dev/null +++ b/app/components/notifications/SmsDisclosure.tsx @@ -0,0 +1,44 @@ +import { m } from "~/paraglide/messages"; + +/** + * What a reader is agreeing to, wherever they agree to it. + * + * Two places capture SMS consent — the public `/sms-optin/:token` page and the + * inline grant on the notifications screen — and both must show the SAME thing, + * because both stamp the same `disclosure_version` into the same ledger. Two + * copies of this markup would let one of them drift, and the drift would be + * invisible: the row would still claim the reader saw version N. + * + * The privacy and terms links belong here for the same reason. They were on the + * opt-in page and missing from the inline grant, which meant one of the two + * paths was recording consent against a disclosure the reader could not fully + * read. + */ +export function SmsDisclosure({ + text, privacyUrl, termsUrl, +}: { + text: string; + privacyUrl?: string | null | undefined; + termsUrl?: string | null | undefined; +}) { + return ( +
+

{text}

+ {(privacyUrl || termsUrl) && ( +

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

+ )} +
+ ); +} 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..a9db26cf5 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,21 @@ 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 ( +
+

{m.portal_notif_heading()}

+

{m.portal_notif_desc()}

+
+ +
+ ); +} diff --git a/app/components/portal/sections/ReportView.tsx b/app/components/portal/sections/ReportView.tsx index 9fafaa10d..dbcc12856 100644 --- a/app/components/portal/sections/ReportView.tsx +++ b/app/components/portal/sections/ReportView.tsx @@ -27,6 +27,8 @@ 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 { primaryBadgeOf } from "../../../../server/lib/credentials/primary"; import { ReportDefectCard } from "./report/ReportDefectCard"; import { PhotoAppendix } from "./report/PhotoAppendix"; import { ReportSignatureBlock } from "./report/ReportSignatureBlock"; @@ -771,8 +773,13 @@ export function ReportView(props: ReportViewProps) {
)} - {/* ── Signature block ──────────────────────────────────────────── */} - c.imageUrl)?.imageUrl ?? null} /> + {/* ── Signature block ────────────────────────────────────────────── + The cover strip above shows EVERY badge; this has room for one, and + `primaryBadgeOf` is where that choice is stated — first badge in the + inspector's own order, the same list they reorder in Licenses & + affiliations. It used to be an inline `.find()` here, which made the + answer an accident of sort order that nobody could aim at. */} + {/* ── 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/app/components/settings/CredentialsEditor.test.tsx b/app/components/settings/CredentialsEditor.test.tsx new file mode 100644 index 000000000..b7a26c0bd --- /dev/null +++ b/app/components/settings/CredentialsEditor.test.tsx @@ -0,0 +1,145 @@ +/** + * 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, fireEvent } from "@testing-library/react"; +import { CredentialsEditor, type EditorCredential } from "./CredentialsEditor"; + +/** + * The cropper is stubbed, not rendered. What these specs are about is WHETHER + * it opens and with WHAT settings — react-easy-crop's own behaviour is not + * this component's contract, and its geometry is covered by crop-rotation. + */ +vi.mock("~/components/media-studio/PhotoCropper", () => ({ + PhotoCropper: (p: { outputFormat?: string; maxLongEdge?: number }) => ( +
+ ), +})); + +function renderEditor(over: Partial[0]>) { + return render( + , + ); +} + +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"); + }); +}); + +/** + * A badge goes through the CROPPER, and comes out as PNG. + * + * It used to be the only image on this page uploaded byte-for-byte off disk — + * no straightening a photographed certificate, no trimming the margin around a + * seal — on the one surface whose whole purpose is appearing on a published + * report. + * + * The format is the part with teeth. A badge is cut out against transparency; + * JPEG has no alpha channel and fills it with white, so the report cover gets a + * white rectangle where the seal should be. Nothing on the settings page would + * show that: the preview there sits on a light card. + */ +describe("CredentialsEditor — cropping a badge", () => { + function pick(container: HTMLElement, file: File) { + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + Object.defineProperty(input, "files", { value: [file], configurable: true }); + fireEvent.change(input); + } + + it("opens the cropper instead of uploading the raw file", () => { + const onUpload = vi.fn(); + const { container } = renderEditor({ onUpload }); + pick(container, new File(["x"], "badge.png", { type: "image/png" })); + expect(screen.getByTestId("cropper")).toBeTruthy(); + // Nothing is sent until the reader saves a crop. + expect(onUpload).not.toHaveBeenCalled(); + }); + + it("bakes PNG, so a transparent badge stays transparent", () => { + const { container } = renderEditor({}); + pick(container, new File(["x"], "badge.png", { type: "image/png" })); + expect(screen.getByTestId("cropper").getAttribute("data-format")).toBe("image/png"); + }); + + it("lets a vector badge through untouched — rasterizing it would be the downgrade", () => { + const onUpload = vi.fn(); + const { container } = renderEditor({ onUpload }); + const svg = new File([""], "seal.svg", { type: "image/svg+xml" }); + pick(container, svg); + expect(screen.queryByTestId("cropper")).toBeNull(); + expect(onUpload).toHaveBeenCalledWith("c1", svg); + }); + + it("refuses an oversized file here, before any request", () => { + const onUpload = vi.fn(); + const { container } = renderEditor({ onUpload }); + const big = new File(["x"], "badge.png", { type: "image/png" }); + Object.defineProperty(big, "size", { value: 3_000_000 }); + pick(container, big); + expect(screen.queryByTestId("cropper")).toBeNull(); + expect(onUpload).not.toHaveBeenCalled(); + expect(screen.getByRole("alert").textContent).toMatch(/2 MB/i); + }); +}); diff --git a/app/components/settings/CredentialsEditor.tsx b/app/components/settings/CredentialsEditor.tsx index 66c3d4f69..bb9fa99c5 100644 --- a/app/components/settings/CredentialsEditor.tsx +++ b/app/components/settings/CredentialsEditor.tsx @@ -3,7 +3,11 @@ // upload control + a Remove button, and label / member number live behind a // collapsed
. A solo inspector pasting one pre-composed badge strip // never touches a text field. No expiry input (Spec B §5). +import { useState } from "react"; +import { IconButton } from "@core/shared-ui"; import { LogoUploader } from "~/components/media-studio/LogoUploader"; +import { PhotoCropper } from "~/components/media-studio/PhotoCropper"; +import { BADGE_MAX_LONG_EDGE, isVectorImage, validateImageFile } from "~/lib/image-upload"; import { m } from "~/paraglide/messages"; export interface EditorCredential { @@ -16,18 +20,86 @@ export interface EditorCredential { export function CredentialsEditor({ credentials, uploadingId, + uploadError, onUpload, onAdd, onUpdate, onDelete, + onReorder, }: { 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; onDelete: (id: string) => void; + /** + * The full list in its new order. Emitting the whole order rather than a + * (id, direction) pair keeps the rule for turning positions into `sortOrder` + * in ONE place — the route — instead of splitting it across a component that + * knows the order and a handler that has to reconstruct it. + */ + onReorder: (orderedIds: string[]) => void; }) { + /** The row whose badge is being cropped, and the object URL it came from. */ + const [cropTarget, setCropTarget] = useState<{ id: string; url: string } | null>(null); + /** + * A file this page refused, before any request was made. + * + * Kept separately from `uploadError` (which is the SERVER's answer) because + * the two arrive at different moments and one does not supersede the other: + * a local refusal has to survive on screen while nothing at all is in flight. + */ + const [localError, setLocalError] = useState<{ id: string; message: string } | null>(null); + + const closeCropper = () => { + if (cropTarget) URL.revokeObjectURL(cropTarget.url); + setCropTarget(null); + }; + + /** + * A badge gets cropped like every other image on this page. + * + * It used to go to the server byte-for-byte, which made it the one image here + * with no way to straighten a photographed certificate or trim the margin + * around a seal — on the surface whose whole purpose is appearing on a + * published report. Vector badges still pass straight through: rasterizing an + * SVG to crop it would throw away the reason it was uploaded as an SVG. + */ + const onSelect = (id: string, file: File) => { + const invalid = validateImageFile(file); + if (invalid) { setLocalError({ id, message: invalid }); return; } + setLocalError(null); + if (isVectorImage(file)) { onUpload(id, file); return; } + setCropTarget({ id, url: URL.createObjectURL(file) }); + }; + + const shownError = localError ?? uploadError; + + /** + * ORDER IS THE CHOICE. The first credential carrying a badge is the one that + * stands beside the signature on every report (`primaryBadgeOf`), so moving a + * row is how an inspector picks it — there is no separate "primary" toggle to + * fall out of step with the list. + */ + const primaryBadgeId = credentials.find((c) => c.imageUrl)?.id ?? null; + + const move = (index: number, delta: number) => { + const next = [...credentials]; + const [row] = next.splice(index, 1); + next.splice(index + delta, 0, row); + onReorder(next.map((c) => c.id)); + }; + return (
@@ -39,13 +111,60 @@ export function CredentialsEditor({

{m.settings_profile_credentials_empty()}

)} - {credentials.map((c) => ( + {credentials.map((c, i) => (
-
- onUpload(c.id, f)} /> + {/* Up/down rather than drag: this list is two to five rows, and + buttons work with a keyboard and with a gloved finger on the iPad + that the drag handle would not. Hidden entirely at one row, where + there is no order to express. */} + {credentials.length > 1 && ( +
+ move(i, -1)} + className="w-7 h-7" + > + + + move(i, 1)} + className="w-7 h-7" + > + + +
+ )} + {/* 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. */} +
+ onSelect(c.id, f)} /> + {/* Says what the order DID, on the row it happened to. A paragraph + above the list would explain the same rule without ever showing + which credential it currently picked. */} + {c.id === primaryBadgeId && ( +

{m.settings_profile_credentials_primary_badge()}

+ )} + {shownError?.id === c.id && ( +

{shownError.message}

+ )}
-
+ {/* 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()}
{m.settings_profile_credentials_add()} + + {cropTarget && ( + { + const { id } = cropTarget; + closeCropper(); + onUpload(id, new File([blob], "badge.png", { type: "image/png" })); + }} + /> + )}
); } diff --git a/app/components/settings/NotificationPreferencesCard.tsx b/app/components/settings/NotificationPreferencesCard.tsx new file mode 100644 index 000000000..534c00066 --- /dev/null +++ b/app/components/settings/NotificationPreferencesCard.tsx @@ -0,0 +1,56 @@ +import { NotificationSettings } from "~/components/notifications/NotificationSettings"; +import type { AlwaysSentItem, ChoiceRow } from "~/components/notifications/NotificationPreferences"; +import type { SmsConsent } from "~/components/notifications/SmsConsentBlock"; +import { useDisplayLocale } from "~/hooks/useSessionContext"; +import { m } from "~/paraglide/messages"; + +/** + * The staff member's own notification settings, on Settings -> Profile (§4.1). + * + * A shell around the shared surface: everything a reader interacts with lives + * in ``, and all this adds is the card chrome and the + * intent names this route's action listens for. + * + * Staff have very little to choose 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 short choose list is the + * honest answer, not a bug. + */ +export function NotificationPreferencesCard({ + alwaysSent, youChoose, loadError, smsConsent, +}: { + alwaysSent: AlwaysSentItem[]; + youChoose: ChoiceRow[]; + /** The read failed. Distinct from "nothing to show". */ + loadError: string | null; + /** + * Staff ARE a consent subject — a `users` row with `contact_id` null. They + * are never granted (implied under account terms, like an agent), so the + * block exists to give their STOP somewhere to land. + */ + smsConsent: SmsConsent | null; +}) { + const locale = useDisplayLocale(); + return ( +
+

+ {m.settings_notifications_eyebrow()} +

+

{m.settings_notifications_heading()}

+

{m.settings_notifications_desc()}

+ +
+ ); +} diff --git a/app/components/settings/ProfilePhotoCard.tsx b/app/components/settings/ProfilePhotoCard.tsx new file mode 100644 index 000000000..d5b0ce25e --- /dev/null +++ b/app/components/settings/ProfilePhotoCard.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from "react"; +import { useFetcher } from "react-router"; +import { AvatarCropper } from "~/components/media-studio/AvatarCropper"; +import { validateImageFile } from "~/lib/image-upload"; +import { useNotificationSaveToast } from "~/hooks/useNotificationSaveToast"; +import { m } from "~/paraglide/messages"; + +/** + * The inspector's profile photo — the third image on this page, and the last + * one still living in the route. + * + * It is here for the same reason the signature cards are: it OWNS ITS OWN SAVE. + * Choosing a file crops, uploads and reloads with no button anywhere in the + * flow, which is the page's rule — and holding that in the route meant the + * route also carried a fetcher, a toast, two pieces of state and a cropper on + * top of the one form it actually submits. + */ +export function ProfilePhotoCard({ photoUrl }: { photoUrl: string | null }) { + const [avatarSource, setAvatarSource] = useState(null); + const [photoError, setPhotoError] = useState(null); + const fetcher = useFetcher<{ success?: boolean; error?: string; intent?: string }>(); + + useEffect(() => { + if (fetcher.state === "idle" && fetcher.data?.success && fetcher.data?.intent === "photo-upload") { + window.location.reload(); + } + }, [fetcher.state, fetcher.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 "no button means it saved", a silent + // failure is the one thing that breaks the rule. + useNotificationSaveToast({ + data: fetcher.data?.intent === "photo-upload" && !fetcher.data?.success ? fetcher.data : null, + failed: true, + error: fetcher.data?.error ?? null, + }); + + const closeCropper = () => { + if (avatarSource) URL.revokeObjectURL(avatarSource); + setAvatarSource(null); + }; + + return ( +
+
+

{m.settings_profile_photo_heading()}

+

{m.settings_profile_photo_subtitle()}

+
+ +
+
+ {photoUrl ? ( + {m.settings_profile_photo_alt()} + ) : ( + {m.settings_profile_photo_none()} + )} +
+
+ {/* The same picker the signature and the badges use: a label wrapping + a visually-hidden input. The browser's raw file input was the odd + one out on a page with three image uploads, and it is the only + control here that cannot be styled to say it is busy. */} + +

{m.settings_profile_photo_hint()}

+ {photoError &&

{photoError}

} +
+
+ + {avatarSource && ( + { + const fd = new FormData(); + fd.append("intent", "photo-upload"); + fd.append("photo", new File([blob], "avatar.jpg", { type: "image/jpeg" })); + fetcher.submit(fd, { method: "POST", encType: "multipart/form-data" }); + closeCropper(); + }} + /> + )} +
+ ); +} diff --git a/app/components/settings/SignatureCards.test.tsx b/app/components/settings/SignatureCards.test.tsx new file mode 100644 index 000000000..e85ff82af --- /dev/null +++ b/app/components/settings/SignatureCards.test.tsx @@ -0,0 +1,187 @@ +/** + * 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"; + +/** + * The cropper is stubbed. These specs are about WHETHER it opens and with what + * settings; react-easy-crop's own behaviour is not this card's contract, and + * the crop geometry is covered by crop-rotation. + */ +vi.mock("~/components/media-studio/PhotoCropper", () => ({ + PhotoCropper: (p: { outputFormat?: string; maxLongEdge?: number }) => ( +
+ ), +})); + +/** + * 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 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 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(); + }); +}); + +/** + * An uploaded signature goes through the CROPPER. + * + * A photographed signature arrives with the rest of the page around it, and + * often a quarter-turn off — the two things a cropper exists to fix. What it + * used to get instead was an invisible canvas downscale, so a crooked mark on a + * sheet of A4 was what every agreement recipient saw. + */ +describe("SavedSignatureCard — cropping an uploaded signature", () => { + function pick(container: HTMLElement, file: File) { + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + Object.defineProperty(input, "files", { value: [file], configurable: true }); + fireEvent.change(input); + } + + it("opens the cropper rather than saving the file as-is", async () => { + const { container, action } = renderInRouter(); + pick(container, new File(["x"], "sig.png", { type: "image/png" })); + expect(await screen.findByTestId("cropper")).toBeTruthy(); + expect(action).not.toHaveBeenCalled(); + }); + + it("bakes PNG — a signature is composited onto a document", async () => { + // As a JPEG the transparency it is cut out against becomes white, and the + // mark lands on the report as a white rectangle. + const { container } = renderInRouter(); + pick(container, new File(["x"], "sig.png", { type: "image/png" })); + expect((await screen.findByTestId("cropper")).getAttribute("data-format")).toBe("image/png"); + }); + + it("refuses an oversized image inline, before any request", async () => { + const { container, action } = renderInRouter(); + const big = new File(["x"], "sig.png", { type: "image/png" }); + Object.defineProperty(big, "size", { value: 3_000_000 }); + pick(container, big); + expect(await screen.findByRole("alert")).toBeTruthy(); + expect(screen.queryByTestId("cropper")).toBeNull(); + expect(action).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/settings/SignatureCards.tsx b/app/components/settings/SignatureCards.tsx new file mode 100644 index 000000000..9e588d151 --- /dev/null +++ b/app/components/settings/SignatureCards.tsx @@ -0,0 +1,266 @@ +import { useState } from "react"; +import { useFetcher } from "react-router"; +import { SignaturePad } from "~/components/SignaturePad"; +import { PhotoCropper } from "~/components/media-studio/PhotoCropper"; +import { blobToDataUri, readAsDataUri, validateImageFile, isVectorImage, SIGNATURE_MAX_LONG_EDGE } from "~/lib/image-upload"; +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()}
+ {/* 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()}

+ )} +
+ ); +} + +/** + * THE signature — the mark applied to agreements and published reports. + * + * 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({ savedSignature }: { savedSignature: string | null }) { + const fetcher = useFetcher(); + const [showPad, setShowPad] = useState(false); + const [uploadError, setUploadError] = useState(null); + /** The object URL of an uploaded image waiting to be cropped. */ + const [cropSource, setCropSource] = useState(null); + const isOurs = fetcher.data?.intent === "save-signature"; + + /** + * 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" }); + }; + + const closeCropper = () => { + if (cropSource) URL.revokeObjectURL(cropSource); + setCropSource(null); + }; + + /** + * A chosen file goes to the CROPPER, not straight to the server. + * + * A photographed signature arrives with the rest of the page around it and + * often a quarter-turn off — the two things the cropper exists to fix. It was + * previously downscaled by a canvas nobody could see and stored as-is, so a + * crooked mark on a sheet of A4 was what every agreement recipient got. + */ + const onFileChosen = async (file: File) => { + const invalid = validateImageFile(file); + if (invalid) { setUploadError(invalid); return; } + setUploadError(null); + if (isVectorImage(file)) { + const dataUri = await readAsDataUri(file); + if (!dataUri) { setUploadError(m.settings_profile_signature_upload_unreadable()); return; } + submit(dataUri); + return; + } + setCropSource(URL.createObjectURL(file)); + }; + + return ( +
+
+

{m.settings_profile_saved_signature_heading()}

+

{m.settings_profile_saved_signature_subtitle()}

+
+ + + {showPad ? ( + 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}

+ )} +
+ )} + + {cropSource && ( + { + const dataUri = await blobToDataUri(blob); + closeCropper(); + if (!dataUri) { setUploadError(m.settings_profile_signature_upload_unreadable()); return; } + submit(dataUri); + }} + /> + )} +
+ ); +} 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/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/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/lib/image-upload.test.ts b/app/lib/image-upload.test.ts new file mode 100644 index 000000000..cbd1075a8 --- /dev/null +++ b/app/lib/image-upload.test.ts @@ -0,0 +1,61 @@ +/** + * The gate every image on Settings → Profile passes before a cropper opens. + * + * These three surfaces — profile photo, signature, badge — used to disagree + * about what they accepted and about what they did with it. The point of a + * shared check is that a 3 MB file is refused the same way, with the same + * sentence, whichever control the reader used. + */ +import { describe, it, expect } from 'vitest'; +import { + IMAGE_UPLOAD_MAX_BYTES, + isVectorImage, + validateImageFile, +} from '~/lib/image-upload'; + +function fileOf(type: string, size = 1000): File { + const f = new File([new Uint8Array(1)], 'x', { type }); + // File size is derived from its parts; override it rather than allocating + // two megabytes of zeroes to test a limit. + Object.defineProperty(f, 'size', { value: size }); + return f; +} + +describe('validateImageFile', () => { + it('accepts the four formats every one of these surfaces takes', () => { + for (const t of ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']) { + expect(validateImageFile(fileOf(t))).toBeNull(); + } + }); + + it('refuses a format the server would refuse anyway, with a reason', () => { + // The server also rejects this. Doing it here is what turns "the button + // did nothing" into a sentence naming the formats that work. + const msg = validateImageFile(fileOf('image/gif')); + expect(msg).toBeTruthy(); + expect(msg).toMatch(/PNG/i); + }); + + it('refuses a file over the limit the server enforces', () => { + const msg = validateImageFile(fileOf('image/png', IMAGE_UPLOAD_MAX_BYTES + 1)); + expect(msg).toBeTruthy(); + expect(msg).toMatch(/2 MB/i); + }); + + it('accepts a file exactly at the limit — the server does', () => { + // Off-by-one here would refuse a file the API accepts, which is worse than + // no check at all: the reader is told no by a page that would have said yes. + expect(validateImageFile(fileOf('image/png', IMAGE_UPLOAD_MAX_BYTES))).toBeNull(); + }); +}); + +describe('isVectorImage', () => { + it('is true only for SVG', () => { + // SVG skips the cropper on purpose: drawing it to a canvas to crop it + // rasterizes it at one fixed size and discards the only property that made + // it worth uploading as a vector. + expect(isVectorImage(fileOf('image/svg+xml'))).toBe(true); + expect(isVectorImage(fileOf('image/png'))).toBe(false); + expect(isVectorImage(fileOf('image/jpeg'))).toBe(false); + }); +}); diff --git a/app/lib/image-upload.ts b/app/lib/image-upload.ts new file mode 100644 index 000000000..82ef6c7d7 --- /dev/null +++ b/app/lib/image-upload.ts @@ -0,0 +1,77 @@ +/** + * The client half of every image a settings page accepts. + * + * Three surfaces on Settings → Profile take an image — the profile photo, the + * signature, and each license or affiliation badge — and until now each did + * something different with it. The photo went through a cropper; the signature + * went through a hand-rolled canvas downscale that no other surface knew about; + * the badge went to the server exactly as it came off disk, up to 2 MB. So the + * one surface whose output is composited onto a published report was the one + * with no way to fix a crooked scan or trim the paper around the mark. + * + * They share a cropper now. What is left here is the part that happens BEFORE + * the cropper opens — deciding whether a file is acceptable, and whether it is + * the kind of image a raster cropper should touch at all. + */ +import { m } from "~/paraglide/messages"; + +/** Raster + vector formats every one of these surfaces accepts. */ +const IMAGE_UPLOAD_TYPES = ["image/png", "image/jpeg", "image/webp", "image/svg+xml"] as const; + +/** The server refuses above this on both the badge and the photo route. */ +export const IMAGE_UPLOAD_MAX_BYTES = 2_000_000; + +/** + * A signature is a wide, thin mark on a line. 600px along its long edge is + * about four times the size it is ever drawn at, and the value lives in a TEXT + * column that is read on every report render and every agreement — so this cap + * is a running cost, not a one-off. + */ +export const SIGNATURE_MAX_LONG_EDGE = 600; + +/** A badge prints beside a line of text, and at most fills a report cover chip. */ +export const BADGE_MAX_LONG_EDGE = 512; + +/** + * Why this file cannot be used, or null if it can. + * + * Returned rather than thrown, and rendered next to the control that produced + * it: a file the reader just chose and we refused involves no request at all, + * so a toast would be reporting on something that never left the page. + */ +export function validateImageFile(file: File): string | null { + if (!(IMAGE_UPLOAD_TYPES as readonly string[]).includes(file.type)) { + return m.settings_profile_signature_upload_bad_type(); + } + if (file.size > IMAGE_UPLOAD_MAX_BYTES) { + return m.settings_profile_signature_upload_too_big(); + } + return null; +} + +/** + * True for images that must NOT go through the raster cropper. + * + * An SVG is resolution-independent and usually already trimmed to its mark; + * drawing it to a canvas to crop it would rasterize it at one fixed size and + * throw away the only property that made it worth uploading. The same reasoning + * the logo uploader has always used — vector art keeps its original format. + */ +export function isVectorImage(file: File): boolean { + return file.type === "image/svg+xml"; +} + +/** Read a File to a data URI, or null when it cannot be read. */ +export function readAsDataUri(file: File): Promise { + return new Promise((resolve) => { + const r = new FileReader(); + r.onload = () => resolve(typeof r.result === "string" ? r.result : null); + r.onerror = () => resolve(null); + r.readAsDataURL(file); + }); +} + +/** Read a baked Blob to a data URI, or null when it cannot be read. */ +export function blobToDataUri(blob: Blob): Promise { + return readAsDataUri(new File([blob], "image", { type: blob.type })); +} diff --git a/app/lib/portal-notification-preferences.ts b/app/lib/portal-notification-preferences.ts new file mode 100644 index 000000000..581bf60a1 --- /dev/null +++ b/app/lib/portal-notification-preferences.ts @@ -0,0 +1,144 @@ +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 + * 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; + /** Null when this reader has no SMS identity to consent with (§4.2). */ + smsConsent: SmsConsent | 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: [], smsConsent: null, error: m.helper_section_service_unavailable() }; + } + 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: [], smsConsent: null, 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() }; +} + +/** 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() }; +} + +/** 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/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/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..8ddb58fdc 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. + */ +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/lib/settings-notifications.server.ts b/app/lib/settings-notifications.server.ts new file mode 100644 index 000000000..9f1c99a06 --- /dev/null +++ b/app/lib/settings-notifications.server.ts @@ -0,0 +1,125 @@ +import type { AlwaysSentItem, ChoiceRow } from "~/components/notifications/NotificationPreferences"; +import type { SmsConsent } from "~/components/notifications/SmsConsentBlock"; +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; + smsConsent: SmsConsent | 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: [], smsConsent: null, 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, smsConsent: body.data.smsConsent ?? null, 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() }; +} + +/** + * 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() }; +} + +/** Turn text messages back on. Staff are implied, so this is a resume. */ +export async function grantNotificationSms( + api: Api, + request: Request, +): Promise<{ success: boolean; error: string | null }> { + const ip = request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for"); + const ua = request.headers.get("user-agent"); + const res = await api.notificationPrefs["notification-preferences"]["sms-consent"].$put( + { json: {} }, + // Absent headers are OMITTED, not sent empty — a consent row claiming + // "we recorded an ip and it was blank" is worse than one with none. + { headers: { ...(ip ? { "cf-connecting-ip": ip } : {}), ...(ua ? { "user-agent": ua } : {}) } }, + ); + return res.ok + ? { success: true, error: null } + : { success: false, error: m.settings_notifications_error() }; +} diff --git a/app/routes.ts b/app/routes.ts index 155e0226b..7a8033f6a 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. @@ -54,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/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/settings-profile.test.tsx b/app/routes/agent/settings-profile.test.tsx index 54e48c848..e76310be3 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: "on", in_app: "on" }, + }, + { + id: "agent-report-ready", + label: "A report is ready to read", + channels: { email: "off", sms: "on", in_app: "on" }, + }, + ], +}; + 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)} + // Not disabled while a preference saves: switching company is a + // NAVIGATION, and the reload it triggers re-reads the answer + // anyway. The save fetcher now lives inside the shared surface, + // which is the right place for it and out of reach from here. + 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 +313,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/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()} diff --git a/app/routes/inspection-edit.tsx b/app/routes/inspection-edit.tsx index 06d6bb04a..9fcab50c9 100644 --- a/app/routes/inspection-edit.tsx +++ b/app/routes/inspection-edit.tsx @@ -70,6 +70,7 @@ import { TagPickerModal } from "~/components/editor/TagPickerModal"; import { useIsMobile } from "~/hooks/useBreakpoint"; import { MobileAppBar } from "~/components/editor/MobileAppBar"; import { MobileDrawerTriggers, type MobileDrawerId } from "~/components/editor/MobileDrawerTriggers"; +import { MobileFinishDrawer } from "~/components/editor/MobileFinishDrawer"; import { MobileBottomDrawer } from "~/components/MobileBottomDrawer"; import { BreadcrumbDropdown, type UnitScopeRow } from "~/components/editor/BreadcrumbDropdown"; import { UnitsManager } from "~/components/editor/UnitsManager"; @@ -629,16 +630,11 @@ export default function InspectionEditPage() { setAutoSign(!!(state.inspection as Record).autoSignOnPublish); }, [state.inspection]); - const handleAutoSignToggle = useCallback( - (checked: boolean) => { - setAutoSign(checked); - signFetcher.submit( - { intent: "toggle-auto-sign", autoSignOnPublish: String(checked) }, - { method: "post" }, - ); - }, - [signFetcher], - ); + // Ticking the box only moves LOCAL state now; the publish request carries + // the value and the server writes it before publishing. Submitting here as + // well would restore the race this replaced, and persisting a choice the + // reader can still cancel out of was never the intent either. + const handleAutoSignToggle = useCallback((checked: boolean) => setAutoSign(checked), []); const handleSignSubmit = useCallback( async (dataUri: string) => { @@ -1761,6 +1757,98 @@ export default function InspectionEditPage() { /* Render */ /* ---------------------------------------------------------------- */ + /** + * The four dialogs that END a job: report settings, sign, the publish + * readiness gate, and publish itself. + * + * Held in a fragment rendered by BOTH the mobile and the desktop tree. They + * used to live only in the desktop one, below the `isMobile` early return — + * so on a phone there was no Publish, no Sign, and no way to reach them: the + * mobile app bar's own "More actions" button was a comment reading + * `future: open more menu`. An inspector could complete the entire + * walkthrough on the device they carry and then not finish it. + */ + const finishActionsEl = ( + <> + {/* Inspection settings sheet */} + state.setSettingsOpen(false)} + inspectionId={String(state.inspection.id)} + // Template schema drives the whole editor state (frozen at mount in useInspection), + // so a template change requires a full route reload — this also fixes the same + // staleness for mid-inspection template switches, not just the empty case. + onTemplateApplied={() => window.location.reload()} + /> + {/* Publish confirmation modal */} + { setPublishError(null); state.setShowPublishModal(false); }} + onPublish={(markComplete: boolean) => { + // Keep the modal open: the publish-result effect closes it on success + // and shows the real server reason inline on failure. `markComplete` + // also closes the order axis first (advisory — never blocks publish); + // patch status locally so the badge reflects it without a reload. + setPublishError(null); + if (markComplete) { + state.setInspection((prev) => ({ ...prev, status: INSPECTION_STATUS.COMPLETED })); + } + // The auto-sign choice rides ALONG WITH the publish, never beside it. + // It used to be a separate fetcher fired by the checkbox, racing this + // request: the publish handler re-reads the inspection row to decide + // whether to sign, and if the toggle had not landed yet it read the OLD + // value and published UNSIGNED. The flag still persisted, so the next + // publish signed — the worst shape for a bug like this, because the + // reader concludes they mis-clicked rather than that a report went out + // unsigned. + publishFetcher.submit( + { + intent: "publish", + autoSignOnPublish: String(autoSign), + ...(markComplete ? { markComplete: "true" } : {}), + }, + { method: "post" }, + ); + }} + autoSign={autoSign} + onAutoSignToggle={handleAutoSignToggle} + /> + {/* Inspector sign modal */} + setSignModalOpen(false)} + failed={Boolean(signFetcher.data && !(signFetcher.data as { ok: boolean }).ok)} + /> + {/* Publish gate modal */} + setShowPublishGate(false)} + onProceed={() => { + // IA-7 warning mode — user acknowledged the soft gaps. + setPublishError(null); + setShowPublishGate(false); + state.setShowPublishModal(true); + }} + onJump={(b: PublishBlockingDefect) => { + state.selectSectionById(b.sectionId); + state.setActiveItemId(b.itemId); + setShowPublishGate(false); + setTimeout(() => { + const sel = b.missing[0] === 'trade' ? 'select' : 'input[type="text"]'; + const el = document.querySelector(`[data-defect-id="${b.cannedId}"] ${sel}`); + if (el) el.focus(); + }, 100); + }} + /> + + ); + if (isMobile) { return (

@@ -1774,7 +1862,7 @@ export default function InspectionEditPage() { if (state.activeItemId) { state.setActiveItemId(null); return; } navigate('/inspections'); }} - onMore={() => { /* future: open more menu */ }} + onMore={() => setMobileDrawer('actions')} />
{emptyTemplateEl ?? (state.activeItemId ? ( @@ -1805,6 +1893,20 @@ export default function InspectionEditPage() { > {sideRailEl} + setMobileDrawer(null)} + onPublish={() => void handlePublishClick()} + onSign={() => setSignModalOpen(true)} + onOpenSettings={() => state.setSettingsOpen(true)} + onFinishFieldwork={(state.inspection.status as string) !== "completed" ? handleFinishFieldwork : null} + finishingFieldwork={completeFetcher.state !== "idle"} + onPreviewReport={ + loaderData.tenantSlug + ? () => window.open(`/report-view/${loaderData.tenantSlug}/${state.inspection.id}`, "_blank", "noopener") + : null + } + /> {/* Theme — narrow-screen home for the theme control the xl+ header shows inline, so the auto/light/dark/field preference is reachable on tablet and phone too. */} @@ -1817,6 +1919,7 @@ export default function InspectionEditPage() {
+ {finishActionsEl}
); } @@ -2007,16 +2110,6 @@ export default function InspectionEditPage() { onCancel={structure.closeSaveTemplate} /> - {/* Inspection settings sheet */} - state.setSettingsOpen(false)} - inspectionId={String(state.inspection.id)} - // Template schema drives the whole editor state (frozen at mount in useInspection), - // so a template change requires a full route reload — this also fixes the same - // staleness for mid-inspection template switches, not just the empty case. - onTemplateApplied={() => window.location.reload()} - /> {/* Media Studio — gallery "Set as cover" crop overlay */} {galleryCropSource && ( @@ -2043,31 +2136,6 @@ export default function InspectionEditPage() { onLeave={confirmLeave} /> - {/* Publish confirmation modal */} - { setPublishError(null); state.setShowPublishModal(false); }} - onPublish={(markComplete: boolean) => { - // Keep the modal open: the publish-result effect closes it on success - // and shows the real server reason inline on failure. `markComplete` - // also closes the order axis first (advisory — never blocks publish); - // patch status locally so the badge reflects it without a reload. - setPublishError(null); - if (markComplete) { - state.setInspection((prev) => ({ ...prev, status: INSPECTION_STATUS.COMPLETED })); - } - publishFetcher.submit( - markComplete ? { intent: "publish", markComplete: "true" } : { intent: "publish" }, - { method: "post" }, - ); - }} - autoSign={autoSign} - onAutoSignToggle={handleAutoSignToggle} - /> {/* #181 — Version history panel (collab Phase 4). Only reachable when the collabEditing flag is on (the trigger button is gated in EditorHeader). @@ -2084,13 +2152,8 @@ export default function InspectionEditPage() { currentResults={state.results as unknown as ResultsProjection} /> - {/* Inspector sign modal */} - setSignModalOpen(false)} - failed={Boolean(signFetcher.data && !(signFetcher.data as { ok: boolean }).ok)} - /> + + {finishActionsEl} {/* Comment library drawer */} setTagPickerOpen(false)} /> - {/* Publish gate modal */} - setShowPublishGate(false)} - onProceed={() => { - // IA-7 warning mode — user acknowledged the soft gaps. - setPublishError(null); - setShowPublishGate(false); - state.setShowPublishModal(true); - }} - onJump={(b: PublishBlockingDefect) => { - state.selectSectionById(b.sectionId); - state.setActiveItemId(b.itemId); - setShowPublishGate(false); - setTimeout(() => { - const sel = b.missing[0] === 'trade' ? 'select' : 'input[type="text"]'; - const el = document.querySelector(`[data-defect-id="${b.cannedId}"] ${sel}`); - if (el) el.focus(); - }, 100); - }} - /> {/* ------------------------------------------------------------ */} {/* Fixed top header with progress bar */} diff --git a/app/routes/inspection-edit/action.server.test.ts b/app/routes/inspection-edit/action.server.test.ts index 1c788cead..17d8f68ff 100644 --- a/app/routes/inspection-edit/action.server.test.ts +++ b/app/routes/inspection-edit/action.server.test.ts @@ -6,6 +6,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; // branching — which endpoints it calls, in what order, for a given intent. const completePost = vi.fn(); const publishPost = vi.fn(); +const inspectionPatch = vi.fn(); +/** Call order across endpoints — the whole point of the auto-sign specs. */ +const calls: string[] = []; vi.mock('~/lib/session.server', () => ({ requireToken: vi.fn().mockResolvedValue('tok'), @@ -16,6 +19,7 @@ vi.mock('~/lib/api-client.server', () => ({ ':id': { complete: { $post: completePost }, publish: { $post: publishPost }, + $patch: inspectionPatch, }, }, })), @@ -72,3 +76,57 @@ describe('editor action — order-lifecycle intents (IA-30 Task 4)', () => { expect(res).toMatchObject({ ok: true, intent: 'publish' }); }); }); + + +/** + * Auto-sign must be WRITTEN BEFORE the publish it was ticked for. + * + * The publish handler decides whether to sign by re-reading the inspection + * row. The checkbox used to fire its own fetcher alongside the publish + * request, so the two raced; when the toggle lost, publish read the old value + * and the report went out UNSIGNED — while the flag still landed, so the NEXT + * publish signed and the reader concluded they had mis-clicked. Verified + * against a real local publish before this was written: first publish left + * `_inspector_signature` absent, second one injected it. + */ +describe('editor action — auto-sign on publish', () => { + beforeEach(() => { + calls.length = 0; + completePost.mockReset().mockImplementation(async () => { calls.push('complete'); return new Response(null, { status: 200 }); }); + publishPost.mockReset().mockImplementation(async () => { calls.push('publish'); return new Response(null, { status: 200 }); }); + inspectionPatch.mockReset().mockImplementation(async () => { calls.push('patch'); return new Response(null, { status: 200 }); }); + }); + + it('writes the flag BEFORE publishing', async () => { + await post({ intent: 'publish', autoSignOnPublish: 'true' }); + expect(inspectionPatch).toHaveBeenCalledWith({ + param: { id: 'insp-1' }, + json: { autoSignOnPublish: true }, + }); + // Order is the assertion. Both being called proves nothing on its own — + // that was true of the racing version too. + expect(calls).toEqual(['patch', 'publish']); + }); + + it('carries an explicit false, so unticking takes effect on this publish', async () => { + // `false` must be written, not omitted: a previously-persisted `true` + // would otherwise sign a report the reader had just opted out of. + await post({ intent: 'publish', autoSignOnPublish: 'false' }); + expect(inspectionPatch).toHaveBeenCalledWith({ + param: { id: 'insp-1' }, + json: { autoSignOnPublish: false }, + }); + expect(calls).toEqual(['patch', 'publish']); + }); + + it('still orders complete -> flag -> publish when marking complete too', async () => { + await post({ intent: 'publish', markComplete: 'true', autoSignOnPublish: 'true' }); + expect(calls).toEqual(['complete', 'patch', 'publish']); + }); + + it('touches nothing when the field is absent — a caller that never asked', async () => { + await post({ intent: 'publish' }); + expect(inspectionPatch).not.toHaveBeenCalled(); + expect(calls).toEqual(['publish']); + }); +}); diff --git a/app/routes/inspection-edit/action.server.ts b/app/routes/inspection-edit/action.server.ts index 037b5b5ef..566b01f8d 100644 --- a/app/routes/inspection-edit/action.server.ts +++ b/app/routes/inspection-edit/action.server.ts @@ -30,6 +30,20 @@ export async function action({ request, params, context }: Route.ActionArgs) { // "Mark complete and publish": fire complete first — idempotent and // non-blocking, so a failure there never stops the publish that follows. if (formData.get("markComplete") === "true") await completeEndpoint().catch(() => undefined); + // AUTO-SIGN IS SET HERE, AND AWAITED, because the publish handler decides + // whether to sign by re-reading the inspection row. The checkbox used to + // fire its own fetcher alongside this request, so the two raced: when the + // toggle lost, publish read the old value and the report went out UNSIGNED + // while the flag still landed — so the NEXT publish signed and the reader + // concluded they had mis-clicked. Ordering it here is what makes the + // checkbox mean what it says on the publish it was ticked for. + const autoSign = formData.get("autoSignOnPublish"); + if (autoSign !== null) { + await api.inspections[":id"].$patch({ + param: { id: params.id }, + json: { autoSignOnPublish: autoSign === "true" }, + }); + } const res = await api.inspections[":id"].publish.$post({ param: { id: params.id }, json: {} }); // Publish has meaningful precondition failures (e.g. "Inspection must be // completed before publishing the report.") that the inspector MUST see — @@ -335,16 +349,7 @@ export async function action({ request, params, context }: Route.ActionArgs) { return { ok: res.ok, intent: "save-structure-template" }; } - if (intent === "toggle-auto-sign") { - const autoSignOnPublish = formData.get("autoSignOnPublish") === "true"; - const res = await api.inspections[":id"].$patch({ - param: { id: params.id }, - json: { autoSignOnPublish }, - }); - ok = res.ok; - } - - if (intent === "sign-inspector") { +if (intent === "sign-inspector") { const signatureBase64 = String(formData.get("signatureBase64") ?? ""); if (signatureBase64) { const res = await api.inspectionSync[":id"]["inspector-signature"].$post({ 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/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-inspection.tsx b/app/routes/public/portal-inspection.tsx index 2be9d7f3f..a8d4f2fc3 100644 --- a/app/routes/public/portal-inspection.tsx +++ b/app/routes/public/portal-inspection.tsx @@ -52,6 +52,13 @@ import { type InvoiceLoaderResult, type AgreementLoaderResult, } from "~/lib/section-loaders"; +import { + bulkPortalNotificationChoice, + grantPortalSmsConsent, + 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 +139,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 +225,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 +253,25 @@ 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, + ); + return { ...r, intent }; + } + + 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 +290,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 +303,7 @@ export default function PortalInspection() { agreement: AgreementLoaderResult | null; agentReport: AgentReportContext | null; notices: PortalNoticesPayload; + notifications: NotificationsLoaderResult | null; }; const revalidator = useRevalidator(); const [searchParams] = useSearchParams(); @@ -369,6 +401,7 @@ export default function PortalInspection() { docError={docError} onUpload={onUpload} onDelete={onDelete} + notifications={notifications} /> ); @@ -383,7 +416,12 @@ export default function PortalInspection() { onSignOut={isAgent ? undefined : () => void signOut(tenant)} bellSlot={ isAgent ? 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/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.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 febaec36a..d85ecac1a 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,15 +8,17 @@ 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"; 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 { ProfilePhotoCard } from "~/components/settings/ProfilePhotoCard"; +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"; /* ------------------------------------------------------------------ */ @@ -27,11 +29,11 @@ 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; signaturePreviewHtml?: string; + savedSignature?: string | null; timezone?: string | null; locale?: string | null; } @@ -43,25 +45,75 @@ 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 }; } /* ------------------------------------------------------------------ */ /* 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"; +} + +/** + * 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 }); const fd = await request.formData(); const intent = fd.get("intent") as string | null; + if (intent === "save-notification") { + return { ...(await saveNotificationChoice(api, fd)), intent }; + } + + if (intent === "bulk-notification") { + 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; @@ -90,30 +142,57 @@ 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 }; } // 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 }; + return { success: false, error: await apiErrorMessage(res), 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); + } + /** + * Reorder = choose. The list order decides the licence line AND the badge + * beside the signature (`primaryLicenseOf` / `primaryBadgeOf`), so this is + * not a cosmetic sort — it is how the inspector says which credential is + * theirs to lead with. + * + * Reindexes the WHOLE list rather than swapping two rows: every credential + * created before this control shipped sits at `sortOrder = 0`, and swapping + * two zeroes is a no-op that looks exactly like a broken button. + */ + if (intent === "credential-reorder") { + const ids = String(fd.get("ids") ?? "").split(",").filter(Boolean); + if (!ids.length) return { success: false, error: m.settings_profile_error_reorder_failed(), intent }; + const results = await Promise.all( + ids.map((id, i) => api.credentials[":id"].$patch({ param: { id }, json: { sortOrder: i } })), + ); + const failed = results.find((r) => !r.ok); + return failed ? credentialResult(failed, intent) : { success: true, error: null, 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; @@ -121,7 +200,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 }; } @@ -133,7 +227,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()}

- )} -
@@ -357,148 +445,64 @@ 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]}
-
+ )} + {/* 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. */} +
+ +
+ - {/* 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()}

- )} -
+ {/* DB-12 / IA-26 — Booking slug section removed; the company booking link + now lives in Settings → Booking ("Your links"). */} - {form.errors && ( -
- {form.errors[0]} -
- )} + - {/* Save — sticky bar pinned to the bottom of the settings scroll area */} - - + {/* Email signature (business-card footer) — independent of Point of Contact */} + - {/* Saved signature */} -
-
-

{m.settings_profile_saved_signature_heading()}

-

- {m.settings_profile_saved_signature_subtitle()} -

-
- - {sigSaved && ( -
- {m.settings_profile_signature_saved_flash()} -
- )} - {sigError && ( -
- {sigError} -
- )} - - {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); - }} - /> - ) : ( - - )} -
+ - {avatarSource && ( - { URL.revokeObjectURL(avatarSource); setAvatarSource(null); }} - onSave={(blob) => { - const fd = new FormData(); - fd.append("intent", "photo-upload"); - fd.append("photo", new File([blob], "avatar.jpg", { type: "image/jpeg" })); - photoFetcher.submit(fd, { method: "POST", encType: "multipart/form-data" }); - URL.revokeObjectURL(avatarSource); - setAvatarSource(null); - }} +
+ - )} +
+
); } diff --git a/docs/sms-compliance.md b/docs/sms-compliance.md index 6a6ef3d06..206e27fb9 100644 --- a/docs/sms-compliance.md +++ b/docs/sms-compliance.md @@ -27,7 +27,17 @@ 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 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/communication.json b/messages/en/communication.json index 09f833ed5..696f5baa6 100644 --- a/messages/en/communication.json +++ b/messages/en/communication.json @@ -56,6 +56,15 @@ "nav_item_messages": "Messages", "notice_panel_title": "Notices", "notice_panel_mark_all": "Mark all read", + "notice_panel_settings": "Notification settings", + "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/messages/en/components.json b/messages/en/components.json index 6d82e3c7e..643ed2dc8 100644 --- a/messages/en/components.json +++ b/messages/en/components.json @@ -146,5 +146,41 @@ "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 can switch off", + "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_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_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", + "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", + "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.", + "notif_prefs_channel_email": "Email", + "notif_prefs_channel_sms": "Text", + "notif_prefs_channel_in_app": "In-app" } diff --git a/messages/en/editor-2.json b/messages/en/editor-2.json index 6f7504006..b4260b4b1 100644 --- a/messages/en/editor-2.json +++ b/messages/en/editor-2.json @@ -22,6 +22,11 @@ "editor_header_preview": "Preview", "editor_header_preview_pdf_title": "Preview the real server-rendered PDF (the exact client deliverable) in a new tab", "editor_header_preview_pdf": "Preview PDF", + "editor_header_preview_title": "See the report the way the client will", + "editor_header_preview_actions_aria": "Preview options", + "editor_header_preview_report": "Web report", + "editor_header_preview_pdf_short": "PDF", + "editor_header_more": "More actions", "editor_header_sign_title": "Sign this inspection now", "editor_header_sign": "Sign now", "editor_header_publish": "Publish", diff --git a/messages/en/media.json b/messages/en/media.json index 0beeac722..6d99b23f5 100644 --- a/messages/en/media.json +++ b/messages/en/media.json @@ -87,5 +87,10 @@ "media_strip_select": "Select", "media_strip_uploading": "Uploading", "media_strip_cover": "Cover", - "media_strip_add_photo_aria": "Add photo" + "media_strip_add_photo_aria": "Add photo", + "media_cropper_rotate_left": "Rotate left", + "media_cropper_rotate_right": "Rotate right", + "media_badge_crop_aria": "Crop badge", + "media_badge_save": "Save badge", + "media_cropper_bake_failed": "That image could not be processed. Try another file." } diff --git a/messages/en/public.json b/messages/en/public.json index 26c87138d..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", @@ -130,14 +129,12 @@ "agent_portal_settings_slug_save": "Save slug", "agent_portal_settings_slug_hint": "Lowercase letters, numbers, and hyphens (3-32 chars).", "agent_portal_settings_notifications_eyebrow": "Notifications", - "agent_portal_settings_notifications_heading": "Email me when...", - "agent_portal_settings_notifications_desc": "High-signal alerts default ON. Toggle off any you don't want.", - "agent_portal_settings_notify_referral_title": "A new referral is booked", - "agent_portal_settings_notify_referral_subtitle": "When a client books an inspection using your referral link.", - "agent_portal_settings_notify_report_title": "A report is ready to read", - "agent_portal_settings_notify_report_subtitle": "When the inspector publishes the report for one of your referrals.", - "agent_portal_settings_notify_paid_title": "An invoice is paid", - "agent_portal_settings_notify_paid_subtitle": "When your client pays the inspection invoice.", + "agent_portal_settings_notifications_heading": "What each company sends you", + "agent_portal_settings_notifications_desc": "You work with these companies separately, so each one has its own settings.", + "agent_portal_settings_notify_company_label": "Company", + "agent_portal_settings_notify_apply_all": "Apply my next change to all {count} companies", + "agent_portal_settings_notify_applied_all": "Applied to all {count} companies.", + "agent_portal_settings_notify_no_companies": "No companies yet. Once an inspection company adds you as an agent, their notification settings appear here.", "agent_portal_settings_slug_error_generic": "Couldn't save your slug. Please try again.", "agent_portal_settings_notify_error_generic": "Couldn't save your notification preferences. Please try again.", "agent_portal_settings_timezone_eyebrow": "Display", @@ -166,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/messages/en/settings.json b/messages/en/settings.json index 827a89ee6..449538904 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", @@ -72,29 +69,38 @@ "settings_profile_photo_none": "No photo", "settings_profile_photo_alt": "Profile", "settings_profile_photo_hint": "JPG, PNG, or WebP. Max 2 MB. Square crop renders best.", - "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_notifications_eyebrow": "Notifications", + "settings_notifications_heading": "What we send you", + "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": "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", "settings_profile_signature_preview_label": "Preview", "settings_profile_signature_empty": "Add your name (and phone/license) above to build a signature.", "settings_profile_save_button": "Save Profile", - "settings_profile_saved_signature_heading": "Saved Signature", - "settings_profile_saved_signature_subtitle": "Your signature is applied to agreements you send and can be used for auto-sign on report publish.", + "settings_profile_saved_signature_heading": "Signature", + "settings_profile_saved_signature_subtitle": "The mark applied to agreements you send and to reports you publish.", "settings_profile_signature_saved_flash": "Signature saved.", - "settings_profile_signature_pad_save": "Save Signature", + "settings_profile_signature_pad_save": "Save signature", "settings_profile_signature_update": "Update signature", "settings_profile_signature_add": "Add signature", "settings_profile_error_no_signature": "No signature data provided", "settings_profile_error_no_photo": "No valid photo provided", "settings_profile_error_upload_failed": "Upload failed", + "settings_profile_error_reorder_failed": "Could not save the new order", + "settings_profile_credentials_move_up": "Move up", + "settings_profile_credentials_move_down": "Move down", + "settings_profile_credentials_primary_badge": "Shown beside your signature", "settings_security_crumb": "Account & Security", "settings_security_subtitle": "Password, two-factor authentication, account data, and security settings.", "settings_security_flash_saved": "Saved.", @@ -227,5 +233,15 @@ "settings_team_cancel_invite_title": "Cancel this invite?", "settings_team_cancel_invite_confirm": "The invite to {email} will be revoked. They will no longer be able to join with the emailed link.", "settings_team_load_failed": "The team list could not be loaded, so it may be incomplete. Reload before assuming someone has been removed.", - "settings_apps_load_failed": "Connected apps could not be loaded. This is not a confirmation that nothing has access — reload before acting on it." + "settings_apps_load_failed": "Connected apps could not be loaded. This is not a confirmation that nothing has access — reload before acting on it.", + "settings_profile_saved_signature_alt": "Your saved signature", + "settings_profile_signature_draw": "Draw signature", + "settings_profile_signature_upload": "Upload image", + "settings_profile_signature_empty_hint": "Nothing signed yet. Draw one, or upload an image of your signature.", + "settings_profile_signature_upload_too_big": "That image is over 2 MB. Try a smaller one.", + "settings_profile_signature_upload_bad_type": "Use a PNG, JPEG, WebP or SVG image.", + "settings_profile_signature_upload_unreadable": "That file could not be read as an image.", + "settings_profile_signature_crop_aria": "Crop signature", + "settings_profile_signature_crop_save": "Save signature", + "settings_profile_photo_choose": "Choose photo" } 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/0019_motionless_dark_phoenix.sql b/migrations/0019_motionless_dark_phoenix.sql new file mode 100644 index 000000000..704b9c157 --- /dev/null +++ b/migrations/0019_motionless_dark_phoenix.sql @@ -0,0 +1,32 @@ +-- Move the three agent notification booleans on `users` into +-- `notification_preferences`, then retire the columns. +-- +-- Only rows that DIFFER from the class default are written. That keeps the +-- table growing with decisions rather than with the user base (design §3.2), +-- and it is why the third statement looks inverted: `is_paid_notification_enabled` +-- defaulted to FALSE, and the class it becomes carries `defaultEnabled: false` +-- to preserve exactly that. So the row to store there is the one where a user +-- had explicitly turned it ON. +-- +-- Without a per-class default this migration had only bad answers: write a mute +-- row for every user, or silently start sending invoice-paid mail to agents who +-- never asked for it. + +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-new-referral', 'email', 0, + CAST(strftime('%s','now') AS INTEGER) * 1000, CAST(strftime('%s','now') AS INTEGER) * 1000 +FROM users WHERE is_referral_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-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/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/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/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/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/0024_drop_users_license_number.sql b/migrations/0024_drop_users_license_number.sql new file mode 100644 index 000000000..d150548b3 --- /dev/null +++ b/migrations/0024_drop_users_license_number.sql @@ -0,0 +1,27 @@ +-- Retire `users.license_number` for real: drop the column, do not freeze it. +-- +-- The backfill already seeded one `inspector_credentials` row per licensed +-- user (`sort_order = -1`), and every renderer now reads +-- `CredentialService.primaryLicenseNumber`. Nothing reads or writes this +-- column, so a frozen-but-present column is only a name people will trip over. +-- Pre-launch, there is no downstream consumer to keep compatible. +-- +-- WHY A BARE `DROP COLUMN` AND NOT A GENERATED MIGRATION +-- +-- `users` is the FK target of six tables (inspection.inspector_id, +-- inspector_assignments, agreements.inspector_user_id, automation rules, +-- integration credentials). The hazard this repository has actually been +-- bitten by is drizzle's 12-step rebuild — CREATE __new_users / INSERT / +-- DROP TABLE users / RENAME — because that `DROP TABLE` on an FK-referenced +-- table is what loses rows on remote D1. +-- +-- SQLite's native `ALTER TABLE … DROP COLUMN` is not a rebuild: the table +-- keeps its identity, so no FK is ever dangling and no data is copied. It is +-- refused only when the column carries an index, a UNIQUE/PK constraint, a +-- CHECK, or a view/trigger reference. `license_number` is a plain nullable +-- TEXT with none of those (verified against `0000_baseline.sql` and every +-- forward migration), which is exactly the case the native form handles. +-- +-- Hand-written rather than generated for that reason: the statement that runs +-- must be this one, and a regenerated file could silently become the rebuild. +ALTER TABLE `users` DROP COLUMN `license_number`; 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/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/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/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/0022_snapshot.json b/migrations/meta/0022_snapshot.json new file mode 100644 index 000000000..0cb64e81b --- /dev/null +++ b/migrations/meta/0022_snapshot.json @@ -0,0 +1,10126 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "057c147b-6909-5a38-80d2-d42f542a82da", + "prevId": "824f1eee-c293-486c-9cd2-d9a1783e5347", + "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 + } + } + } + } + } +} diff --git a/migrations/meta/0023_snapshot.json b/migrations/meta/0023_snapshot.json new file mode 100644 index 000000000..11a490612 --- /dev/null +++ b/migrations/meta/0023_snapshot.json @@ -0,0 +1,10126 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0023-notification-pref-enabled-rename", + "prevId": "057c147b-6909-5a38-80d2-d42f542a82da", + "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/0024_snapshot.json b/migrations/meta/0024_snapshot.json new file mode 100644 index 000000000..866b6509c --- /dev/null +++ b/migrations/meta/0024_snapshot.json @@ -0,0 +1,10119 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f4a25042-4ded-529b-8ffc-ae3883d3ac0b", + "prevId": "0023-notification-pref-enabled-rename", + "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 + }, + "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 d5380da67..db1383189 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -127,6 +127,55 @@ "when": 1785421496479, "tag": "0017_strange_multiple_man", "breakpoints": true + }, + { + "idx": 18, + "version": "6", + "when": 1785470983411, + "tag": "0018_charming_wild_pack", + "breakpoints": true + }, + { + "idx": 19, + "version": "6", + "when": 1785479812967, + "tag": "0019_motionless_dark_phoenix", + "breakpoints": true + }, + { + "idx": 20, + "version": "6", + "when": 1785502662839, + "tag": "0020_fancy_toxin", + "breakpoints": true + }, + { + "idx": 21, + "version": "6", + "when": 1785515513924, + "tag": "0021_gigantic_bruce_banner", + "breakpoints": true + }, + { + "idx": 22, + "version": "6", + "when": 1785515514924, + "tag": "0022_license_number_backfill", + "breakpoints": true + }, + { + "idx": 23, + "version": "6", + "when": 1785515515924, + "tag": "0023_notification_pref_enabled_rename", + "breakpoints": true + }, + { + "idx": 24, + "version": "6", + "when": 1785515515925, + "tag": "0024_drop_users_license_number", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9e3cc6904..35ff82f90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,7 @@ "@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", "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.10.1", @@ -104,6 +105,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 +4489,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", @@ -6624,6 +6662,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 +8862,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 +10409,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 +11832,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 +12862,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 b540d92f8..8f31f78c6 100644 --- a/package.json +++ b/package.json @@ -38,12 +38,14 @@ "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:migchain && 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", "lint:migrefs": "node scripts/check-migration-refs.mjs", + "lint:migchain": "node scripts/check-migration-chain.mjs", "lint:filesize": "node scripts/check-file-size.mjs", "lint:dup": "jscpd server app packages", "lint:gates": "node scripts/run-gates.mjs", @@ -51,6 +53,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 +93,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:migchain && 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": { @@ -150,6 +153,7 @@ "@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", "@types/better-sqlite3": "^7.6.13", "@types/node": "^24.10.1", 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/packages/shared-ui/src/FileDropzone.tsx b/packages/shared-ui/src/FileDropzone.tsx index b15d7db26..6a11d22b3 100644 --- a/packages/shared-ui/src/FileDropzone.tsx +++ b/packages/shared-ui/src/FileDropzone.tsx @@ -219,7 +219,11 @@ export function FileDropzone({ type="file" accept={accept} disabled={!interactive} - className="hidden" + // sr-only, NOT hidden. A `display:none` input is not reliably + // clickable — `inputRef.click()` can be declined with nothing on + // screen to say so, and the zone then simply does not respond. + // Visually hidden keeps it a rendered, focusable control. + className="sr-only" onChange={(e) => { const file = e.target.files?.[0]; if (file) onFile(file); 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/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-migration-chain.mjs b/scripts/check-migration-chain.mjs new file mode 100644 index 000000000..651312b53 --- /dev/null +++ b/scripts/check-migration-chain.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +/** + * Drizzle meta-chain gate (`lint:migchain`). + * + * Every `migrations/meta/NNNN_snapshot.json` must exist and its `prevId` must + * name the previous snapshot's `id`. drizzle-kit walks that chain to diff the + * schema, so a single missing or mislinked snapshot makes `db:generate` refuse + * to run at all: + * + * Error: [meta/0021_snapshot.json, meta/0023_snapshot.json] are pointing to + * a parent snapshot: meta/0021_snapshot.json/snapshot.json which is a collision. + * + * This rotted unnoticed for two migrations because nothing checked it. `db:check` + * does not: it applies the SQL and compares the resulting TABLES, so a chain that + * drizzle-kit cannot walk still produces a correct database and a green gate. The + * two checks answer different questions — "do the migrations build the schema" + * versus "can drizzle still author the next one" — and only the first had a gate. + * + * Hand-written migrations are the usual cause: a data-only migration ships with + * no snapshot (it changes no DDL, so it feels like it needs none), and the next + * generated one then links past it. A data migration still needs a link — its + * snapshot is simply its predecessor's schema under a new id. + */ +import { readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const metaDir = join(root, 'migrations', 'meta'); +const journal = JSON.parse(readFileSync(join(metaDir, '_journal.json'), 'utf8')); + +const problems = []; +let prev = null; + +journal.entries.forEach((entry, i) => { + const file = join(metaDir, `${String(i).padStart(4, '0')}_snapshot.json`); + if (!existsSync(file)) { + problems.push( + `${entry.tag}: no snapshot at meta/${String(i).padStart(4, '0')}_snapshot.json.\n` + + ` A data-only migration still needs one — copy its predecessor's snapshot,\n` + + ` give it a fresh id, and point prevId at the predecessor.`, + ); + prev = null; // chain is severed; do not cascade a second error onto the next entry + return; + } + const snap = JSON.parse(readFileSync(file, 'utf8')); + if (prev !== null && snap.prevId !== prev.id) { + problems.push( + `${entry.tag}: prevId is ${snap.prevId}, but the previous snapshot ` + + `(${prev.tag}) has id ${prev.id}.`, + ); + } + prev = { id: snap.id, tag: entry.tag }; +}); + +const seen = new Map(); +journal.entries.forEach((entry, i) => { + const file = join(metaDir, `${String(i).padStart(4, '0')}_snapshot.json`); + if (!existsSync(file)) return; + const { id } = JSON.parse(readFileSync(file, 'utf8')); + if (seen.has(id)) problems.push(`${entry.tag}: duplicate snapshot id ${id} (also ${seen.get(id)}).`); + else seen.set(id, entry.tag); +}); + +if (problems.length) { + console.error('[migchain] FAIL — the drizzle meta chain is broken:\n'); + for (const p of problems) console.error(` - ${p}`); + console.error('\n While it is broken, `npm run db:generate` cannot run.'); + process.exit(1); +} + +console.log(`[migchain] OK — ${journal.entries.length} snapshots, chain intact.`); diff --git a/scripts/check-notification-dispatch.mjs b/scripts/check-notification-dispatch.mjs new file mode 100644 index 000000000..c38e2015f --- /dev/null +++ b/scripts/check-notification-dispatch.mjs @@ -0,0 +1,197 @@ +#!/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'; +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'); + +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); +} + +// 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 }); + +// ── 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 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$/, +]; + +// ── 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 SCANNED) { + 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); + // `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'); + } + } + } + + 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); +} + +stamp.save(); +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 7b4297759..e17fefd5b 100644 --- a/scripts/file-size-baseline.json +++ b/scripts/file-size-baseline.json @@ -1,51 +1,51 @@ { - "app/routes/inspection-edit.tsx": 2489, + "app/routes/inspection-edit.tsx": 2530, "app/routes/inspector-portal.tsx": 1180, "server/services/inspection/inspection-core.service.ts": 1117, - "server/services/booking.service.ts": 969, - "server/services/inspection/inspection-report.service.ts": 920, + "server/services/booking.service.ts": 966, + "server/services/inspection/inspection-report.service.ts": 952, "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": 813, "app/routes/settings-communication.tsx": 777, - "server/services/inspection.service.ts": 755, - "server/api/inspections/report-delivery.ts": 733, + "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, "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": 688, "app/hooks/usePhotoOps.ts": 661, "server/lib/messaging/providers/telnyx-compliance.ts": 657, "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, + "server/api/public-report.ts": 567, "server/api/auth.ts": 564, "server/services/agent/referral.ts": 564, "server/api/inspections/core.ts": 561, "app/lib/collab/results-binding.ts": 560, "server/api/calendar.ts": 547, - "server/api/public-report.ts": 536, "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/api/portal.ts": 525, "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, + "app/routes/settings-profile.tsx": 509, + "app/routes/inspection-edit/action.server.ts": 501, "server/services/inspection-request.service.ts": 501, "server/services/report-export-consumer.ts": 499, - "app/routes/inspection-edit/action.server.ts": 496, + "server/api/repair-builder.ts": 497, "app/components/collab/VersionHistoryPanel.tsx": 478, "app/routes/settings-workspace.tsx": 477, "server/api/bookings.ts": 477, @@ -53,14 +53,15 @@ "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, "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, - "server/lib/middleware/di.ts": 417, + "server/lib/middleware/di.ts": 422, "app/routes/templates.tsx": 414, - "app/routes/calendar.tsx": 410 + "app/routes/calendar.tsx": 410, + "app/lib/section-loaders.ts": 402 } 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/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/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..087e9208b --- /dev/null +++ b/server/api/agent/notification-preferences.ts @@ -0,0 +1,302 @@ +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 { applyBulk, assertChoosable, readChoices, writeChoice } from '../../lib/notifications/preference-write'; +import { listAgentCompanies } from '../../services/agent/companies'; +import { grantSms, readSmsConsent, revokeChannel } from '../../lib/notifications/channel-consent'; +import { SmsConsentService } from '../../services/sms-consent.service'; +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 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({ + 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() }), + })), + smsConsent: SmsConsentSchema, + }), +}).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 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', + 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 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 () => {}); + const agentUserId = c.get('user').sub; + 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) + : 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), + // 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', [{ kind: 'contact' as const, id: selected.contactId }], disclosure) + : null, + }, + }, 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, '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); + }) + .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 } : {}) }, + ); + // 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', [{ kind: 'contact' as const, id: t.contactId }], null); + await revokeChannel(new SmsConsentService(c.env.DB), t.tenantId, 'sms', block, 'agent'); + } + } + 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; +export type AgentNotificationPreferencesApi = typeof agentNotificationPreferenceRoutes; 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/credentials.ts b/server/api/credentials.ts index 310d3cc0c..8c0113ee9 100644 --- a/server/api/credentials.ts +++ b/server/api/credentials.ts @@ -83,7 +83,7 @@ const uploadRouteDef = createRoute(withMcpMetadata({ middleware: [OWN] as const, request: { params: z.object({ id: z.string().min(1).describe('The credential id whose badge image is being uploaded or replaced.') }), - body: { content: { 'multipart/form-data': { schema: z.object({ image: z.any().openapi({ type: 'string', format: 'binary' }).describe('The badge image file (png, svg, jpeg, or webp; up to 2MB).') }) } } }, + body: { content: { 'multipart/form-data': { schema: z.object({ image: z.any().openapi({ type: 'string', format: 'binary' }).describe('The badge image file (png, svg, or webp — formats that carry transparency; up to 2MB).') }) } } }, }, responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.literal(true), data: z.object({ imageUrl: z.string() }) }) } }, description: 'Uploaded' } }, operationId: 'uploadInspectorCredentialImage', @@ -91,7 +91,17 @@ const uploadRouteDef = createRoute(withMcpMetadata({ }, { scopes: ['write'], tier: 'extended' })); const MAX_BADGE_BYTES = 2_000_000; -const ALLOWED = ['image/png', 'image/svg+xml', 'image/jpeg', 'image/webp']; +/** + * Badge formats that can carry an ALPHA CHANNEL. JPEG is deliberately absent. + * + * A badge is composited over the report cover strip and beside the signature, + * both of which sit on the report's own surface — so a format with no + * transparency renders the association logo inside a white rectangle, and in + * dark mode that rectangle is the most prominent thing on the page. The + * cropper already bakes PNG on the way out, so this is the guard for anything + * reaching the endpoint directly (API tokens, `extended` tier). + */ +const ALLOWED = ['image/png', 'image/svg+xml', 'image/webp']; const credentialsRoutes = createApiRouter() .openapi(listRoute, async (c) => { @@ -133,7 +143,7 @@ const credentialsRoutes = createApiRouter() const file = formData.get('image'); if (!file || !(file instanceof File)) throw Errors.BadRequest('No image file provided.'); if (file.size > MAX_BADGE_BYTES) throw Errors.BadRequest('image > 2MB'); - if (!ALLOWED.includes(file.type)) throw Errors.BadRequest('image must be png, svg, jpeg, or webp'); + if (!ALLOWED.includes(file.type)) throw Errors.BadRequest('image must be png, svg, or webp — a badge needs a transparent background'); const imageUrl = await c.var.services.credentials.uploadImage(tenantId, userId, id, file); auditFromContext(c, 'credential.image_uploaded', 'credential', { entityId: id }); return c.json({ success: true as const, data: { imageUrl } }, 200); 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/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/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..3a7e57877 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); @@ -217,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/api/notification-preferences.ts b/server/api/notification-preferences.ts new file mode 100644 index 000000000..e8f95d097 --- /dev/null +++ b/server/api/notification-preferences.ts @@ -0,0 +1,223 @@ +import { createRoute, z } from '@hono/zod-openapi'; +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 { grantSms, readSmsConsent, revokeChannel } from '../lib/notifications/channel-consent'; +import { SmsConsentService } from '../services/sms-consent.service'; + +/** + * 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 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']); + +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(), + }), + })), + 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(), + 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(), + }), +}).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.'), + 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 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 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; + const userId = c.get('user')?.sub as string; + const db = getDrizzle(c); + + // 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); + // 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 }, + }, 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'); + + // 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, 'staff'); + + await writeChoice(getDrizzle(c), { + 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 db = getDrizzle(c); + const stored = await applyBulk( + 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); + }) + .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; +export type NotificationPreferencesApi = typeof notificationPreferenceRoutes; diff --git a/server/api/portal.ts b/server/api/portal.ts index 857c99e9d..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,22 +332,12 @@ 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 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); + 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. + 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/portal/notification-preferences.ts b/server/api/portal/notification-preferences.ts new file mode 100644 index 000000000..5e8f8d61a --- /dev/null +++ b/server/api/portal/notification-preferences.ts @@ -0,0 +1,277 @@ +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 { applyBulk, assertChoosable, readChoices, writeChoice } from '../../lib/notifications/preference-write'; +import { contactIdsForEmail } from '../../services/notice-inbox'; +import { grantSms, readSmsConsent, revokeChannel } from '../../lib/notifications/channel-consent'; +import { SmsConsentService } from '../../services/sms-consent.service'; +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 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({ + 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() }), + })), + smsConsent: SmsConsentSchema, + }), +}).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.'), +}); + + +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).'), +}); + +/** 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; +} + +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 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 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) => { + 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); + } + } + const disclosure = await new SmsConsentService(c.env.DB).currentDisclosure(); + const smsConsent = await readSmsConsent(db, tenantId, 'client', asContacts(contactIds), disclosure); + return c.json({ + success: true as const, + data: { ...buildScreenModel('client', chosen), smsConsent }, + }, 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, '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); + }) + .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); + } + // 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', asContacts(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', asContacts(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; +export type PortalNotificationPreferencesApi = typeof portalNotificationPreferenceRoutes; diff --git a/server/api/profile.ts b/server/api/profile.ts index 47241d1d1..5b8fcd4a7 100644 --- a/server/api/profile.ts +++ b/server/api/profile.ts @@ -1,6 +1,7 @@ import { createRoute, z } from '@hono/zod-openapi'; import { createApiRouter } from '../lib/openapi-router'; import { drizzle } from 'drizzle-orm/d1'; +import { CredentialService } from '../services/credential.service'; import { and, eq } from 'drizzle-orm'; import { Errors } from '../lib/errors'; import { createApiResponseSchema } from '../lib/validations/shared.schema'; @@ -35,10 +36,10 @@ 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(), + savedSignature: z.string().nullable().describe('The inspector drawn signature as a data URI, or null when none is saved.'), signaturePreviewHtml: z.string(), timezone: z.string().nullable(), locale: z.string().nullable(), @@ -58,7 +59,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).'), @@ -70,7 +70,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: { @@ -131,12 +131,16 @@ const profileRoutes = createApiRouter() name: users.name, email: users.email, phone: users.phone, - licenseNumber: users.licenseNumber, slug: users.slug, photoUrl: users.photoUrl, signatureEnabled: users.signatureEnabled, timezone: users.timezone, locale: users.locale, + // The drawn signature itself. Settings said "Signature saved" and + // showed the reader nothing — so the one thing they might want to + // check, that the right mark was captured, was the one thing the + // page would not tell them. + savedSignature: users.defaultSignatureBase64, }).from(users) .where(and(eq(users.id, userId), eq(users.tenantId, tenantId))) .get(); @@ -145,11 +149,24 @@ const profileRoutes = createApiRouter() const host = new URL(c.req.url).host; const tenantSlug = c.get('requestedTenantSlug') ?? null; + // The preview MUST carry credentials, or it shows a signature the + // reader will never receive. `inspectorSignature` has accepted them + // 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 credentials = await new CredentialService(c.env.DB).listRenderable(tenantId, userId); + const signaturePreviewHtml = (row.name ?? '').trim() ? inspectorSignature({ name: row.name, email: row.email, phone: row.phone, - licenseNumber: row.licenseNumber, tenantSlug, - }, host).html + tenantSlug, credentials, + // RELATIVE badge URLs. `host` here is the in-process API + // request's, which in local dev is a different port than the + // browser is on, so absolutizing against it pointed every badge + // at a port with nothing behind it. The preview is drawn on this + // app's own origin; letting the browser resolve them is both + // simpler and immune to that whole class of mismatch. + }, host, { assetOrigin: '' }).html : ''; return c.json({ @@ -167,7 +184,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; @@ -208,9 +224,15 @@ const profileRoutes = createApiRouter() const buf = new Uint8Array(await file.arrayBuffer()); await r2Put(c.env.PHOTOS, key, buf, { httpMetadata: { contentType: file.type } }); - const host = (c.env.APP_BASE_URL?.replace(/^https?:\/\//, '').replace(/\/$/, '')) || c.req.header('host') || ''; - const proto = c.env.APP_BASE_URL?.startsWith('http://') ? 'http' : 'https'; - const photoUrl = `${proto}://${host}/photos/${key}`; + // A RELATIVE path, stored. Every consumer of `users.photo_url` is a + // browser surface — this page, the public booking page, concierge, + // presence — so each already has the right origin and nothing needs one + // baked in. The absolute form was built from the request's host, which + // for an in-process API call is not the host the browser is on: the + // photo saved fine and then rendered as a broken image, pointing at a + // port nobody was serving. An absolute URL is also a hostage to the + // deploy origin never changing, for no benefit here. + const photoUrl = `/photos/${key}`; await getDrizzle(c).update(users) .set({ photoUrl }) .where(and(eq(users.id, userId), eq(users.tenantId, tenantId))); diff --git a/server/api/public-report.ts b/server/api/public-report.ts index c2fb62472..a6e7f01ce 100644 --- a/server/api/public-report.ts +++ b/server/api/public-report.ts @@ -18,7 +18,7 @@ import { InvoiceNotPayableError } from '../lib/stripe-helpers'; import { logger } from '../lib/logger'; import { buildRenderReportUrl } from '../lib/public-urls'; import { getBookingHost, getBaseUrl } from '../lib/url'; -import { publicReportAccessAllowed } from '../lib/report-access'; +import { publicReportAccessAllowed, shouldPinLatestPublished } from '../lib/report-access'; import publicVerifyRoutes from './public/verify'; import publicInspectorProfileRoutes from './public/inspector-profile'; import { PublicInvoiceBodySchema } from '../lib/validations/invoice.schema'; @@ -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) @@ -283,6 +288,32 @@ const publicReportRoutes = createApiRouter() // THIS inspection is enforced by getReportData's tenant-scoped query. let ownerPreview = false; if (!tenantId) { tenantId = await resolveOwnerPreview(c); ownerPreview = !!tenantId; } + + // OPTION A — a delivered report renders what it FROZE. + // + // A recipient reading their link gets the latest PUBLISHED version's + // snapshot, not live tables. Without this the everyday web read tracked + // current state, so an inspector who renewed a licence or left an + // association silently retro-edited every report they had ever issued — + // on a document that carries a signature and an integrity hash claiming + // it had not changed. + // + // Three deliberate exclusions: + // - RENDER TOKEN already names its own version (above); it must keep + // it, because the verify page materialises one specific version. + // - OWNER PREVIEW stays LIVE. The owner is the author: preview exists + // to show work in progress, and pinning it would hide every edit made + // after the last publish. This is the plan's own split — "publish → + // snapshot; live/draft preview → read current state". + // - AN UNPUBLISHED report has no version row, so it resolves live by + // falling through with `pinnedVersion` still undefined. + // + // Server-derived, like the render token's: nothing here reads a version + // from the query string, so a link holder still cannot ask for one. + if (tenantId && shouldPinLatestPublished({ renderMode, ownerPreview, tokenPinnedVersion: pinnedVersion })) { + const latest = await c.var.services.reportVersion.getLatestPublished(tenantId, id); + if (latest) pinnedVersion = latest.versionNumber; + } if (!tenantId) { // IA-36 ⑨ — distinguish "this link was taken offline" from "this link // never existed". The recipient was invited by us and the link died by @@ -337,7 +368,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/inspector-profile.ts b/server/api/public/inspector-profile.ts index 04c42543d..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', @@ -42,6 +45,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.'), })), }, }, @@ -62,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' }, @@ -71,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'); @@ -100,10 +127,18 @@ 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'); + 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: @@ -112,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/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/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/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/index.ts b/server/index.ts index 3c886866a..de751db4d 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'; @@ -90,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'; @@ -358,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) @@ -424,6 +428,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/compliance/erasure-manifest.ts b/server/lib/compliance/erasure-manifest.ts index d9611ea65..cad654365 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 @@ -185,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: '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/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/credentials/primary.ts b/server/lib/credentials/primary.ts new file mode 100644 index 000000000..183bb909c --- /dev/null +++ b/server/lib/credentials/primary.ts @@ -0,0 +1,44 @@ +/** + * Which single credential answers a surface that has room for exactly one. + * + * These are pure rules over a resolved list, and they live HERE rather than in + * `CredentialService` because the report renderer needs them and importing the + * service would pull drizzle and the D1 schema into the client bundle for the + * sake of two `.find()` calls. + * + * Both rules read the same way — FIRST MATCH IN THE INSPECTOR'S OWN ORDER — + * which is the point. The inspector orders their credentials once, in Licenses + * & affiliations, and that single act decides both the licence line and the + * badge beside the signature. No second "primary" flag to drift out of sync + * with the list it describes. + */ + +/** The shape both rules read. Structural, so callers need not import the DTO. */ +export interface PrimaryCandidate { + memberNumber: string | null; + imageUrl: string | null; +} + +/** + * The LICENCE among a set of credentials, or null. + * + * "First entry carrying a member number" works because the backfill seeds the + * state licence at `sort_order = -1`; that sort order was chosen for this. + */ +export function primaryLicenseOf(credentials: PrimaryCandidate[]): string | null { + return credentials.find((c) => (c.memberNumber ?? '').trim())?.memberNumber?.trim() || null; +} + +/** + * The ONE badge that stands beside the signature, or null. + * + * The report cover shows every badge; the signature block has room for one, and + * which one it got used to be decided by an inline `.find()` in JSX — so the + * answer was "whichever row happened to sort first", with no way for the + * inspector to say which they meant. The rule itself was never wrong; being + * unnamed and unreachable was, because a rule you cannot state is a rule you + * cannot deliberately satisfy. + */ +export function primaryBadgeOf(credentials: PrimaryCandidate[]): string | null { + return credentials.find((c) => c.imageUrl)?.imageUrl ?? null; +} diff --git a/server/lib/db/schema/compliance.ts b/server/lib/db/schema/compliance.ts index a808cf037..3009b1194 100644 --- a/server/lib/db/schema/compliance.ts +++ b/server/lib/db/schema/compliance.ts @@ -63,16 +63,47 @@ 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(), - 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(), + /** + * 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/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..36f8220e1 --- /dev/null +++ b/server/lib/db/schema/notification-preferences.ts @@ -0,0 +1,56 @@ +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(), + // 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) => [ + // 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/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/db/schema/tenant/user.ts b/server/lib/db/schema/tenant/user.ts index aecdb752c..d54547bc4 100644 --- a/server/lib/db/schema/tenant/user.ts +++ b/server/lib/db/schema/tenant/user.ts @@ -18,7 +18,6 @@ export const users = sqliteTable('users', { passwordHash: text('password_hash').notNull(), name: text('name'), phone: text('phone'), - licenseNumber: text('license_number'), // Inspector avatar shown on the public company booking page (/book/:tenant). photoUrl: text('photo_url'), // Spec 5H D2 — saved signature used for auto-sign on publish + Settings prefill. @@ -52,9 +51,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/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 f8433aef4..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: false, - 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: false, - 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: false, - 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: false, - 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: false, - 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: false, - 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: false, - 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: false, - 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 d21fb488a..f37d6dfc0 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); @@ -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) { @@ -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 { @@ -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/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/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/inspector-signature.ts b/server/lib/inspector-signature.ts index 8b4d09452..06e7de732 100644 --- a/server/lib/inspector-signature.ts +++ b/server/lib/inspector-signature.ts @@ -16,11 +16,12 @@ * 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; phone?: string | null; - licenseNumber?: string | null; /** * DB-12 / IA-26 — inspector booking slugs are retired. This field is * retained so existing callers do not need to change their call sites, but @@ -62,28 +63,61 @@ const phoneTel = (raw: string | null | undefined): string | null => { return `+1${digits.slice(-10)}`; }; -export function inspectorSignature(user: SignatureUser, host: string): SignatureOutput { +export interface SignatureRenderOptions { + /** + * Where badge `` URLs point, when that is not where the LINKS point. + * + * The two have different requirements and only one caller notices. An EMAIL + * needs both absolute — a mail client renders the footer wherever the + * recipient happens to be, and resolves nothing against an origin of ours. + * The SETTINGS PREVIEW is drawn by a browser already sitting on this app's + * origin, so it passes `''` and its badges become relative: they then load + * from whatever origin is actually serving the page. + * + * Absolutizing them was what broke the preview. `host` there comes from the + * in-process API request, which in local dev reports a different port than + * the browser is on, so every badge resolved to a port with nothing behind + * it — broken-image icons on the one surface whose entire job is showing + * what the recipient will see. Relative cannot have that class of bug, + * in dev or in production. + */ + assetOrigin?: string; +} + +export function inspectorSignature( + user: SignatureUser, + host: string, + { assetOrigin = `https://${host}` }: SignatureRenderOptions = {}, +): SignatureOutput { + const origin = `https://${host}`; 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); // DB-12 / IA-26 — the per-inspector URL is retired; link to the company // booking page instead. tenantSlug alone is sufficient now. const link = user.tenantSlug - ? `https://${host}/book/${escapeHtml(user.tenantSlug)}` + ? `${origin}/book/${escapeHtml(user.tenantSlug)}` : null; 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) { 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('/') ? `${assetOrigin}${sized}` : sized; return `${escapeHtml(c.label || 'Credential')}`; }) .join(''); @@ -100,7 +134,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()) @@ -114,7 +147,7 @@ export function inspectorSignature(user: SignatureUser, host: string): Signature } // DB-12 / IA-26 — company-level URL only; per-inspector slug retired. if (user.tenantSlug) { - textLines.push(`Book again: https://${host}/book/${user.tenantSlug}`); + textLines.push(`Book again: ${origin}/book/${user.tenantSlug}`); } const text = textLines.join('\n'); diff --git a/server/lib/mcp/openapi-snapshot.json b/server/lib/mcp/openapi-snapshot.json index a762f2e85..1d063996c 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", @@ -6730,6 +6832,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", @@ -7434,6 +7563,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", @@ -7477,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 @@ -8499,6 +8652,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", @@ -9502,7 +9715,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", @@ -14237,11 +14450,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" @@ -14258,7 +14466,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", @@ -14357,6 +14565,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", @@ -14402,6 +14664,67 @@ "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": "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", @@ -14683,6 +15006,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": [ @@ -14693,6 +15024,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", @@ -16030,6 +16412,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", @@ -16059,6 +16495,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", @@ -19678,7 +20156,6 @@ "label": { "type": "string", "maxLength": 120, - "default": "", "description": "Human-readable credential label shown on reports, e.g. \"InterNACHI Certified Professional Inspector\"." }, "memberNumber": { @@ -19775,11 +20252,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/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/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/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/channel-consent.ts b/server/lib/notifications/channel-consent.ts new file mode 100644 index 000000000..f02ca84ff --- /dev/null +++ b/server/lib/notifications/channel-consent.ts @@ -0,0 +1,223 @@ +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'; + +/** + * 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' | '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. + */ + subjects: Array<{ kind: 'contact' | 'user'; id: 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; +} + +/** + * Read the SMS consent block for one reader. + * + * 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, + subjects: Array<{ kind: 'contact' | 'user'; id: string }>, + disclosure: { version: number; text: string } | null, +): Promise { + 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 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 + // 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.subjectId, subjects.map((s) => s.id)), + )) + // 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, 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', + }; + } + + // Nothing on file. What that MEANS depends on who is asking: a business + // 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 === 'client' ? 'none' : 'implied', + at: null, capturedVia: null, subjects, disclosure, + mode: audience === 'client' ? 'express' : 'implied', + }; +} + +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' | 'settings_page', + meta: { + ip?: string | undefined; userAgent?: string | undefined; + recipientType?: 'client' | 'agent' | 'other' | 'staff'; + subjectKind?: 'contact' | 'user'; + }, + ): Promise; +} + +/** + * 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. + * + * 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, + tenantId: string, + block: SmsConsentBlock, + audience: Audience, + meta: { ip?: string | undefined; userAgent?: string | undefined }, +): Promise { + for (const s of block.subjects) { + await recorder.record(tenantId, s.id, 'granted', 'settings_page', { + ...meta, recipientType: basisFor(audience), subjectKind: s.kind, + }); + } +} + +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. + 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/notifications/classes.ts b/server/lib/notifications/classes.ts new file mode 100644 index 000000000..2e536ef79 --- /dev/null +++ b/server/lib/notifications/classes.ts @@ -0,0 +1,239 @@ +/** + * 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". + * + * 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 + * 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'; + +/** + * 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'; + +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[]; + /** + * 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; + /** + * 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. + * + * §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'], 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'], 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'], 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 + // 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. + // + // 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'], 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, 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'], 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). 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'], defaultEnabled: false }, + + // ─── 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'], 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'], 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'], 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'], 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'], 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])); + +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. + */ +/** 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 new file mode 100644 index 000000000..d47a4f394 --- /dev/null +++ b/server/lib/notifications/preference-port.ts @@ -0,0 +1,125 @@ +import { drizzle } from 'drizzle-orm/d1'; +import { and, eq, inArray, or } from 'drizzle-orm'; +import { contacts, notificationPreferences, users } from '../db/schema'; +import { defaultEnabled, 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; +} + +/** 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; + // 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); + 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), + )) + .get(); + + // 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; +} + +/** + * 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 { + // Cheap exit before touching the DB at all. + 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 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/lib/notifications/preference-write.ts b/server/lib/notifications/preference-write.ts new file mode 100644 index 000000000..0c2220ca9 --- /dev/null +++ b/server/lib/notifications/preference-write.ts @@ -0,0 +1,185 @@ +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'; + +/** + * 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. + */ + +/** 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'; + subjectId: string; + classId: string; + channel: 'email' | 'sms' | 'in_app'; + enabled: boolean; +} + +/** + * 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 audience. Each throws a 400 with a + * sentence a reader could act on rather than a code. + */ +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.'); + // 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) { + 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])); +} + +/** + * 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)`, 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 + 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) => ALL_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/notifications/screen-model.ts b/server/lib/notifications/screen-model.ts new file mode 100644 index 000000000..184018df3 --- /dev/null +++ b/server/lib/notifications/screen-model.ts @@ -0,0 +1,86 @@ +import { NOTIFICATION_CLASSES, defaultEnabled, 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. */ +type ChannelState = 'on' | 'off'; + +interface ScreenRow { + id: string; + label: string; + /** + * 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>; +} + +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)); +} + +/** + * 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 + * not always — see `defaultEnabled`. Only differences from the + * default are stored, so this map stays small (§3.2). + */ +export function buildScreenModel(audience: Audience, chosen: ReadonlyMap): 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, + (chosen.get(`${c.id}:${ch}`) ?? defaultEnabled(c.id)) ? 'on' : 'off', + ])) as ScreenRow['channels'], + })), + }; +} diff --git a/server/lib/pdf.ts b/server/lib/pdf.ts index e893d4bdf..165e71346 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. +// Bumped so cached PDFs are re-rendered instead of serving a footer built from +// a column that no longer exists. +export const RENDER_VERSION = 'r11'; /** * Backoff before the single pass-2 retry when Browser Rendering rate-limits the 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-access.ts b/server/lib/report-access.ts index 69c2ad491..1c9a739ac 100644 --- a/server/lib/report-access.ts +++ b/server/lib/report-access.ts @@ -16,3 +16,32 @@ export function publicReportAccessAllowed(opts: { if (opts.renderMode || opts.ownerPreview) return true; return isReportPublished(opts.reportStatus); } + +/** + * Decide whether this read should be PINNED to the latest published version's + * snapshot rather than resolved from live tables. + * + * Only the recipient track is pinned, and the two exclusions are not symmetric + * with the access gate above — which is exactly why this is its own function + * instead of a second `renderMode || ownerPreview` inline: + * + * - RENDER TOKEN carries its own version and must keep it; the verify page + * materialises one specific version, not "the latest". + * - OWNER PREVIEW stays LIVE. The owner is the author, and preview exists to + * show work in progress — pinning it would hide every edit made since the + * last publish, which is the plan's split: publish → snapshot, live/draft + * preview → read current state. + * + * A draft has no version row, so the caller resolves nothing and falls through + * to live. Nothing here consults the query string: the version is always + * server-derived, so a link holder cannot ask for a version they were not sent. + */ +export function shouldPinLatestPublished(opts: { + renderMode: boolean; + ownerPreview: boolean; + /** A version already named by a signed render token, if any. */ + tokenPinnedVersion: number | undefined; +}): boolean { + if (opts.tokenPinnedVersion !== undefined) return false; + return !opts.renderMode && !opts.ownerPreview; +} 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/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 38bb74804..5695d9fa3 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,52 @@ 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; + 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 +75,7 @@ export async function resolveSignatureInspector( c: Context, inspectorId: string | null | undefined, tenantId: string, -): Promise { +): Promise { if (!inspectorId) return undefined; try { const db = getDrizzle(c); @@ -37,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(); @@ -45,7 +90,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; @@ -65,12 +111,12 @@ 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))) .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/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; + /** 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; + /** + * 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; +} + +/** 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, + 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.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; +} + +/** + * 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, 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 + // 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' }; + } + } + + // ── 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 }); + 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/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/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/lib/validations/credential.schema.ts b/server/lib/validations/credential.schema.ts index 2e8b0701a..35dca78bf 100644 --- a/server/lib/validations/credential.schema.ts +++ b/server/lib/validations/credential.schema.ts @@ -6,7 +6,22 @@ export const CreateCredentialSchema = z.object({ memberNumber: z.string().max(60).nullable().optional().describe('Optional membership or license number displayed alongside the credential label.'), sortOrder: z.number().int().optional().describe('Display order for this credential within the inspector list (ascending).'), }); -export const UpdateCredentialSchema = CreateCredentialSchema.partial(); +/** + * Spelled out rather than `CreateCredentialSchema.partial()`. + * + * `.partial()` does NOT remove a field's `.default()` — it wraps it — so + * `parse({ sortOrder: 0 })` came back as `{ label: '', sortOrder: 0 }` and the + * service, which writes any key that is not `undefined`, cleared the label. + * Every PATCH that named one field silently blanked another: renaming a + * credential's member number erased its label, and so did reordering the list. + * + * A PATCH must carry exactly the fields the caller sent. No defaults here. + */ +export const UpdateCredentialSchema = z.object({ + label: z.string().max(120).optional().describe('Human-readable credential label shown on reports, e.g. "InterNACHI Certified Professional Inspector".'), + memberNumber: z.string().max(60).nullable().optional().describe('Optional membership or license number displayed alongside the credential label.'), + sortOrder: z.number().int().optional().describe('Display order for this credential within the inspector list (ascending).'), +}); export const CredentialSchema = z.object({ id: z.string(), label: z.string(), diff --git a/server/lib/validations/inspection/crud.ts b/server/lib/validations/inspection/crud.ts index fc66f9776..b90450506 100644 --- a/server/lib/validations/inspection/crud.ts +++ b/server/lib/validations/inspection/crud.ts @@ -180,7 +180,7 @@ export const UpdateInspectionSchema = z.object({ requireDefectFieldsOverride: z.enum(['none', 'location', 'trade', 'both']).nullable().optional().describe('Per-inspection override of which defect fields the publish gate requires; null inherits the tenant default.'), // Per-inspection override of the tenant's auto-sign-on-publish default // (maps to inspections.auto_sign_on_publish). Toggled from the editor's - // "toggle-auto-sign" action via PATCH /{id}. + // publish request via PATCH /{id}, ordered before the publish itself. autoSignOnPublish: z.boolean().optional().openapi({ example: true }).describe('Whether the inspector signature is auto-applied when the report is published.'), // The settings sheet's "Template" selector reassigns the inspection's // template (inspections.template_id). The column is a free-text id diff --git a/server/lib/version-diff.ts b/server/lib/version-diff.ts index fe9c1f620..47770e487 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 inspector a PINNED version credits, or null when the snapshot cannot say. + * + * 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 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 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 pinnedLead( + snapshot: Snapshot | null | undefined, +): SnapshotInspector | null { + const inspectors = snapshot?.inspectors; + if (!inspectors?.length) return null; + return inspectors.find((i) => i.role === 'lead') ?? inspectors[0] ?? null; +} + 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/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/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/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/send-one-sms.ts b/server/services/automation/send-one-sms.ts index d81c30a89..7f8d62308 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,13 +57,20 @@ 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; /** 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; @@ -107,8 +117,9 @@ async function resolveRecipientRoleKind( } export async function sendOneSms(args: SendOneSmsArgs): Promise { + const { classId } = args; const { - db, rawDb, log, inspection, tenant, bodyTemplate, sms, + db, log, inspection, tenant, bodyTemplate, sms, appName, appHost, env, quotaGuard, metering, } = args; @@ -116,82 +127,53 @@ 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, + // 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)); 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 +189,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..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'; @@ -63,11 +64,12 @@ export function AutomationSms>(Base: T await sendOneSms({ db, - rawDb: this.db, log, 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/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/server/services/booking.service.ts b/server/services/booking.service.ts index 2bbd7e0ee..ef92f2c94 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. @@ -816,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 8f3fad47b..7ea656975 100644 --- a/server/services/credential.service.ts +++ b/server/services/credential.service.ts @@ -4,9 +4,25 @@ import type { InferSelectModel } from 'drizzle-orm'; import { inspectorCredentials } from '../lib/db/schema'; import { r2Keys } from '../lib/r2-keys'; import { Errors } from '../lib/errors'; +import { primaryLicenseOf } from '../lib/credentials/primary'; 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 +37,52 @@ 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, + })); + } + + /** + * 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. A dedicated `users` column used + * to answer this; it has been dropped, 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 { + return primaryLicenseOf(await this.listRenderable(tenantId, userId)); + } + async create( tenantId: string, userId: string, diff --git a/server/services/email/agent.ts b/server/services/email/agent.ts index 780c1bbaa..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'; @@ -48,7 +47,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 +79,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,22 +108,18 @@ export function AgentEmailMixin(Base: TBase) { html: fallbackBody, }); if (!rendered.enabled) return; - await this.sendEmail([to], rendered.subject, rendered.html); + await this.sendRendered(rendered, [to]); } /** * 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 = `
@@ -141,23 +135,19 @@ 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]); } /** * 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

@@ -172,22 +162,18 @@ 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]); } /** * 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 = `
@@ -200,7 +186,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..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 }); } @@ -82,7 +96,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 +172,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 @@ -177,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/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..7d2aac682 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,16 +77,45 @@ 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 }, ); 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. * @@ -143,10 +171,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..f817ce880 100644 --- a/server/services/email/transactional.ts +++ b/server/services/email/transactional.ts @@ -20,7 +20,29 @@ export function TransactionalEmailMixin(Base: TBase) html: fallbackBody, }); if (!rendered.enabled) return; - await this.sendEmail([to], rendered.subject, rendered.html); + 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]); } /** @@ -35,7 +57,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 +90,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 }, ); @@ -84,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 @@ -119,12 +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}`; - - await this.sendEmail([owner.email], subject, html); + 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'); } @@ -199,7 +232,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 +268,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/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-publish.service.ts b/server/services/inspection/inspection-publish.service.ts index 91ac6adda..591ce7036 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(); + // The licence is a credential row — `users` carries no licence column. + 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 f430398b4..665a9aad0 100644 --- a/server/services/inspection/inspection-report.service.ts +++ b/server/services/inspection/inspection-report.service.ts @@ -1,6 +1,10 @@ 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, type RenderableCredential } from '../credential.service'; +import { primaryLicenseOf } from '../../lib/credentials/primary'; +import { pinnedLead } 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 +109,35 @@ 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. + // + // WHAT THE OVERLAY COVERS, precisely, because the gap matters: inspector + // identity + credentials, and the resolved style profile. Everything the + // snapshot does not carry still resolves live. That is less alarming than + // it sounds — the report's STRUCTURE is already frozen elsewhere, at + // creation, by `inspections.template_snapshot` and + // `inspection_results.rating_system_snapshot` — but it is not "the whole + // report is immutable", and anything added to the snapshot later has to + // be overlaid here too or it silently keeps tracking live state. + // + // (An earlier version of this comment claimed the payload advertises + // `snapshotSchemaVersion`. It does not — nothing emits that field. Said + // here instead of promising a field that does not exist.) + 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(); @@ -457,26 +487,29 @@ 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. + // + // The licence is a credential row, seeded ahead of the voluntary badges + // by the backfill; `users` carries no licence column. + const lead = pinnedLead(pinned); 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 }) + 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); - 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 })); + credentialSnapshot = await new CredentialService(this.db) + .listRenderable(tenantId, inspection.inspectorId); + inspectorLicense = primaryLicenseOf(credentialSnapshot); } // Sprint 2 S2-4 — per-tenant flag controls whether the published @@ -526,7 +559,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 }, @@ -871,7 +907,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 @@ -900,15 +936,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/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/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/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/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/e2e/agent-unified-link.spec.ts b/tests/e2e/agent-unified-link.spec.ts index c1bfb73d3..124badde5 100644 --- a/tests/e2e/agent-unified-link.spec.ts +++ b/tests/e2e/agent-unified-link.spec.ts @@ -453,9 +453,16 @@ test.describe.serial('Agent unified link (Spec 3 Task 8)', () => { await expect(freshPage.getByTestId('agent-report-actions')).toBeVisible({ timeout: 10000 }); await freshPage.goto(`${BASE_URL}/agent-dashboard`, { waitUntil: 'networkidle', timeout: NAV_TIMEOUT }); - // requireToken() (app/lib/session.server.ts) finds no session/cookie — - // the report token was never exchanged for one — and bounces to /login. - expect(freshPage.url()).toContain('/login'); + // requireToken() (app/lib/session.server.ts) finds no session/cookie — the + // report token was never exchanged for one — and bounces to a login door. + // + // Specifically the AGENT one: `loginPathFor` sends anything under + // `/agent-` to `/agent-login`, so a session ends at the door it was opened + // at. This assertion used to read `toContain('/login')`, which passed only + // while agents were bounced to the staff login — and note that + // `/agent-login` does NOT contain `/login`, so the old spelling would keep + // failing here rather than accidentally passing. + expect(freshPage.url()).toContain('/agent-login'); await expect(freshPage.getByRole('heading', { name: 'Agent Dashboard' })).toHaveCount(0); } finally { await close(); 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). 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/agreements/inspector-signature.spec.ts b/tests/unit/agreements/inspector-signature.spec.ts index 7df0c154b..0ad6b7b4b 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` carries no licence column. + credentials: [ + { label: 'Licensed home inspector', memberNumber: 'TX-INSP-9001', imageUrl: null }, + ], } as const; const HOST = 'app.inspectorhub.io'; @@ -53,6 +57,36 @@ describe('inspectorSignature — Sprint B-4 / DB-12', () => { expect(sig.text).not.toContain(' { + const sig = inspectorSignature({ + ...FULL_USER, + credentials: [{ label: 'InterNACHI CPI', memberNumber: '12345', imageUrl: '/api/public/brand-asset?key=k.png' }], + }, HOST, { assetOrigin: '' }); + expect(sig.html).toContain(' { + const sig = inspectorSignature({ + ...FULL_USER, + credentials: [{ label: 'InterNACHI CPI', memberNumber: '12345', imageUrl: '/api/public/brand-asset?key=k.png' }], + }, HOST); + expect(sig.html).toContain(`— 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/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/client-portal/portal-routes.spec.ts b/tests/unit/client-portal/portal-routes.spec.ts index e7617defe..47abeda82 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,62 @@ 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(); + }); + + /** + * 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 () => { diff --git a/tests/unit/credentials/badge-upload-formats.spec.ts b/tests/unit/credentials/badge-upload-formats.spec.ts new file mode 100644 index 000000000..0a18d5a86 --- /dev/null +++ b/tests/unit/credentials/badge-upload-formats.spec.ts @@ -0,0 +1,44 @@ +/** + * Which image formats a credential badge may be uploaded in. + * + * A badge is composited over the report cover strip and beside the signature. + * Both sit on the report's own surface, so a format with no alpha channel puts + * the association logo inside a white rectangle — and in dark mode that + * rectangle is the loudest thing on the page. JPEG cannot be transparent, so it + * is not a badge format, however ordinary an image format it is elsewhere. + * + * The rule is asserted against the ROUTE SOURCE rather than through a mounted + * request because the allowlist is a plain module constant: a spec that stood + * up the whole OpenAPI router to read one array back would be testing Hono. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const SRC = readFileSync(join(process.cwd(), 'server', 'api', 'credentials.ts'), 'utf8'); + +/** The `const ALLOWED = [...]` literal, as the route actually declares it. */ +function allowlist(): string[] { + const m = /const ALLOWED = \[([^\]]*)\]/.exec(SRC); + if (!m) throw new Error('ALLOWED allowlist not found in server/api/credentials.ts'); + return [...m[1].matchAll(/'([^']+)'/g)].map((x) => x[1]); +} + +describe('credential badge upload formats', () => { + it('accepts exactly the formats that can carry transparency', () => { + expect(allowlist().sort()).toEqual(['image/png', 'image/svg+xml', 'image/webp']); + }); + + it('refuses JPEG — it has no alpha channel', () => { + expect(allowlist()).not.toContain('image/jpeg'); + }); + + it('tells the uploader why, rather than just listing formats', () => { + // "must be png, svg, or webp" leaves someone holding a JPEG with no idea + // what to do; naming the reason tells them to re-export instead. + const message = /Errors\.BadRequest\('([^']*image must be[^']*)'\)/.exec(SRC)?.[1]; + expect(message).toBeDefined(); + expect(message).toMatch(/transparent/i); + expect(message).not.toMatch(/jpe?g/i); + }); +}); 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/credentials/license-backfill.spec.ts b/tests/unit/credentials/license-backfill.spec.ts new file mode 100644 index 000000000..69fc82833 --- /dev/null +++ b/tests/unit/credentials/license-backfill.spec.ts @@ -0,0 +1,209 @@ +/** + * 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 DROP_FILE = readdirSync(MIGRATION_DIR) + .filter((f) => f.endsWith('_drop_users_license_number.sql')) + .sort() + .at(-1); +if (!DROP_FILE) throw new Error('license_number drop migration not found in migrations/'); +const DROP = readFileSync(join(MIGRATION_DIR, DROP_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 drop = () => sqlite.exec(DROP); + const creds = () => db.select().from(schema.inspectorCredentials).all(); + const licenseColumnExists = () => + (sqlite.prepare( + "SELECT count(*) AS n FROM pragma_table_info('users') WHERE name = 'license_number'", + ).get() as { n: number }).n > 0; + + /** + * `users.license_number` is GONE from the drizzle schema — the drop migration + * below is what removed it. So the fixture re-creates it by hand and inserts + * through raw SQL: this pair of migrations still runs, in this order, on every + * fresh database, and a spec that could only describe the world after the drop + * could not test the backfill at 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', + ...(opts.deletedAt ? { deletedAt: opts.deletedAt } : {}), + createdAt: new Date(), + }); + sqlite.prepare('UPDATE users SET license_number = ? WHERE id = ?').run(licenseNumber, id); + } + + beforeEach(async () => { + const fix = createTestDb(); + db = fix.db; + sqlite = fix.sqlite; + await setupSchema(fix.sqlite); + sqlite.exec('ALTER TABLE users ADD COLUMN license_number text'); + 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('drops the column only AFTER the credential row exists', async () => { + // The order is the whole safety property. Backfill first, drop second: + // reverse them and every licensed inspector loses their licence with no + // way back, because the column was the only place it lived. + await user('u1', 'TX-9001'); + run(); + expect(creds()[0].memberNumber).toBe('TX-9001'); + + drop(); + expect(licenseColumnExists()).toBe(false); + // The credential must survive the drop — it is the licence now. + expect(creds()[0].memberNumber).toBe('TX-9001'); + }); + + it('drops the column in place, without rebuilding the FK-referenced table', async () => { + // A generated migration would have emitted the 12-step rebuild + // (CREATE __new_users / INSERT / DROP TABLE users / RENAME), and that + // `DROP TABLE` on a table six others reference is what loses rows on + // remote D1. SQLite's native DROP COLUMN keeps the table's identity, so + // the assertion that matters is that the other rows are still here. + await user('u1', 'TX-9001'); + await user('u2', null); + run(); + drop(); + + const rows = await db.select().from(schema.users).all(); + expect(rows.map((r) => r.id).sort()).toEqual(['u1', 'u2']); + // Statements only — the migration's own comment NAMES the rebuild it is + // avoiding, so matching the raw file would match the explanation. + const statements = DROP.split('\n').filter((l) => !l.trim().startsWith('--')).join('\n'); + expect(/DROP TABLE/i.test(statements)).toBe(false); + expect(/ALTER TABLE `?users`? DROP COLUMN/i.test(statements)).toBe(true); + }); +}); diff --git a/tests/unit/credentials/primary-rules.spec.ts b/tests/unit/credentials/primary-rules.spec.ts new file mode 100644 index 000000000..3249c052a --- /dev/null +++ b/tests/unit/credentials/primary-rules.spec.ts @@ -0,0 +1,64 @@ +/** + * Which single credential answers a surface with room for exactly one. + * + * Both rules read the same list in the same direction, and that is the design: + * the inspector orders their credentials ONCE, and that single act decides both + * the licence line and the badge beside the signature. A separate "primary" + * flag would be a second source of truth to drift out of step with the list. + */ +import { describe, it, expect } from 'vitest'; +import { primaryLicenseOf, primaryBadgeOf } from '../../../server/lib/credentials/primary'; + +const cred = (memberNumber: string | null, imageUrl: string | null = null) => ({ memberNumber, imageUrl }); + +describe('primaryLicenseOf', () => { + it('takes the first entry carrying a member number', () => { + expect(primaryLicenseOf([cred(null), cred('TX-9001'), cred('N-2')])).toBe('TX-9001'); + }); + + it('ignores whitespace-only numbers rather than printing a blank licence', () => { + expect(primaryLicenseOf([cred(' '), cred('TX-9001')])).toBe('TX-9001'); + }); + + it('trims what it returns', () => { + expect(primaryLicenseOf([cred(' TX-9001 ')])).toBe('TX-9001'); + }); + + it('is null when there is none', () => { + expect(primaryLicenseOf([])).toBeNull(); + expect(primaryLicenseOf([cred(null), cred('')])).toBeNull(); + }); +}); + +describe('primaryBadgeOf', () => { + it('takes the first entry carrying an image', () => { + expect(primaryBadgeOf([cred(null), cred(null, '/a.png'), cred(null, '/b.png')])).toBe('/a.png'); + }); + + it('skips text-only credentials sitting ahead of the badges', () => { + // The backfilled state licence sorts at -1 and has no image, so this is + // the ordinary case rather than an edge one. + expect(primaryBadgeOf([cred('TX-9001'), cred(null, '/assoc.png')])).toBe('/assoc.png'); + }); + + it('is null when the inspector has no badge at all', () => { + expect(primaryBadgeOf([])).toBeNull(); + expect(primaryBadgeOf([cred('TX-9001'), cred('N-2')])).toBeNull(); + }); +}); + +describe('reordering is how the choice is made', () => { + it('changes both answers by changing nothing but the order', () => { + const nachi = cred('N-1', '/nachi.png'); + const ashi = cred('A-2', '/ashi.png'); + + expect(primaryLicenseOf([nachi, ashi])).toBe('N-1'); + expect(primaryBadgeOf([nachi, ashi])).toBe('/nachi.png'); + + // Move ASHI up in Licenses & affiliations and the report follows. This + // is the whole contract of the reorder control: there is no other + // switch, and no state that could disagree with the list. + expect(primaryLicenseOf([ashi, nachi])).toBe('A-2'); + expect(primaryBadgeOf([ashi, nachi])).toBe('/ashi.png'); + }); +}); diff --git a/tests/unit/credentials/service.spec.ts b/tests/unit/credentials/service.spec.ts index 82541a04e..d543bab27 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,118 @@ 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']); + }); +}); + +/** + * `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. A dedicated `users` column used to answer this and is gone, + * 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/credentials/update-schema.spec.ts b/tests/unit/credentials/update-schema.spec.ts new file mode 100644 index 000000000..835bce1be --- /dev/null +++ b/tests/unit/credentials/update-schema.spec.ts @@ -0,0 +1,45 @@ +/** + * A PATCH must carry exactly the fields the caller sent. + * + * `UpdateCredentialSchema` used to be `CreateCredentialSchema.partial()`, and + * `.partial()` does not remove a `.default()` — it wraps it. So a body naming + * one field came out of validation naming two, and `CredentialService.update` + * writes every key that is not `undefined`. Renaming a member number blanked + * the label; so did reordering the list, which is how this was found: the + * reorder control saved the order and quietly emptied every label with it. + * + * These assert the ABSENCE of keys, which is the property that broke. Asserting + * the parsed values would have passed the whole time. + */ +import { describe, it, expect } from 'vitest'; +import { CreateCredentialSchema, UpdateCredentialSchema } from '../../../server/lib/validations/credential.schema'; + +describe('UpdateCredentialSchema', () => { + it('adds no key the caller did not send', () => { + expect(Object.keys(UpdateCredentialSchema.parse({ sortOrder: 0 }))).toEqual(['sortOrder']); + expect(Object.keys(UpdateCredentialSchema.parse({ memberNumber: 'TX-1' }))).toEqual(['memberNumber']); + expect(Object.keys(UpdateCredentialSchema.parse({}))).toEqual([]); + }); + + it('never synthesises an empty label', () => { + // The exact shape of the bug: reordering wrote label ''. + expect(UpdateCredentialSchema.parse({ sortOrder: 3 })).not.toHaveProperty('label'); + }); + + it('still passes through a label the caller DID send, including a blank one', () => { + // Clearing a label on purpose has to keep working — the fix is about + // what is absent, not about refusing empty strings. + expect(UpdateCredentialSchema.parse({ label: '' })).toEqual({ label: '' }); + expect(UpdateCredentialSchema.parse({ label: 'InterNACHI CPI' })).toEqual({ label: 'InterNACHI CPI' }); + }); + + it('lets memberNumber be nulled explicitly', () => { + expect(UpdateCredentialSchema.parse({ memberNumber: null })).toEqual({ memberNumber: null }); + }); + + it('leaves CREATE defaulting its label — a new row genuinely has none', () => { + // The default is right where it lives; it was only wrong once `.partial()` + // carried it into a patch. + expect(CreateCredentialSchema.parse({})).toMatchObject({ label: '' }); + }); +}); 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/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-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..db2208e83 100644 --- a/tests/unit/email/email-registry.spec.ts +++ b/tests/unit/email/email-registry.spec.ts @@ -1,22 +1,33 @@ 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']); - }); - 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']); + 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 + // 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; @@ -36,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-signature-integration.spec.ts b/tests/unit/email/email-signature-integration.spec.ts index 471a57965..7ce915e31 100644 --- a/tests/unit/email/email-signature-integration.spec.ts +++ b/tests/unit/email/email-signature-integration.spec.ts @@ -5,7 +5,6 @@ const STUB_INSPECTOR = { name: 'Mike Reynolds', email: 'mike@acme.test', phone: '(303) 555-0142', - licenseNumber: 'TX-INSP-9001', slug: 'mike', tenantSlug: 'acme', }; @@ -108,3 +107,77 @@ 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('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 ?? ''; + // 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(' ({ 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/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(); + }); +}); 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..5ba0182d3 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,72 @@ 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', + // 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); + } + + 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', + // 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); + 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..8868335cc 100644 --- a/tests/unit/messaging/sms-api.spec.ts +++ b/tests/unit/messaging/sms-api.spec.ts @@ -1539,6 +1539,54 @@ 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', + // 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(); + + 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..402c9b26b --- /dev/null +++ b/tests/unit/messaging/sms-send-gate.spec.ts @@ -0,0 +1,276 @@ +/** + * `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, + // 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 }); + +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('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'); + 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', + }); + }); +}); + +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' }); + }); +}); + +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/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/automation-classes.spec.ts b/tests/unit/notifications/automation-classes.spec.ts new file mode 100644 index 000000000..c1b82f48c --- /dev/null +++ b/tests/unit/notifications/automation-classes.spec.ts @@ -0,0 +1,111 @@ +/** + * 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('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. + expect(automationClassId({ name: 'My own rule', trigger: 'report.published' })).toBeUndefined(); + expect(automationClassId(null)).toBeUndefined(); + }); +}); diff --git a/tests/unit/notifications/channel-consent.spec.ts b/tests/unit/notifications/channel-consent.spec.ts new file mode 100644 index 000000000..a7791afcd --- /dev/null +++ b/tests/unit/notifications/channel-consent.spec.ts @@ -0,0 +1,135 @@ +/** + * 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('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).toHaveLength(1); + expect(rows[0]).toMatchObject({ action: 'granted', recipientType: 'staff', subjectKind: 'user' }); + }); + + 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[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 () => { + 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(); + }); +}); diff --git a/tests/unit/notifications/classes.spec.ts b/tests/unit/notifications/classes.spec.ts new file mode 100644 index 000000000..476dabe6a --- /dev/null +++ b/tests/unit/notifications/classes.spec.ts @@ -0,0 +1,135 @@ +/** + * 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', + // 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-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', + // 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. */ +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', + // §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', () => { + 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('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); + }); +}); diff --git a/tests/unit/notifications/preference-bulk.spec.ts b/tests/unit/notifications/preference-bulk.spec.ts new file mode 100644 index 000000000..ffc5c26cc --- /dev/null +++ b/tests/unit/notifications/preference-bulk.spec.ts @@ -0,0 +1,124 @@ +/** + * 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' }); + + // 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); + 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('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' }); + 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 () => { + 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(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); + }); + + 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); + }); +}); diff --git a/tests/unit/notifications/preference-enforcement.spec.ts b/tests/unit/notifications/preference-enforcement.spec.ts new file mode 100644 index 000000000..3ee015d18 --- /dev/null +++ b/tests/unit/notifications/preference-enforcement.spec.ts @@ -0,0 +1,294 @@ +/** + * 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, 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 +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('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({ + 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); + }); +}); + +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); + }); +}); diff --git a/tests/unit/notifications/preferences-api.spec.ts b/tests/unit/notifications/preferences-api.spec.ts new file mode 100644 index 000000000..f13ac3af5 --- /dev/null +++ b/tests/unit/notifications/preferences-api.spec.ts @@ -0,0 +1,178 @@ +/** + * 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'; +/** + * 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 }; + +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(), { + classId: MUTABLE, 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(); + await put(app, { classId: MUTABLE, channel: 'email', enabled: false }); + expect(await rows()).toHaveLength(1); + + 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(); + await put(app, { classId: MUTABLE, channel: 'email', enabled: false }); + await put(app, { classId: MUTABLE, 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', 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 }> }; + }; + 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: MUTABLE, channel: 'email', enabled: false, + createdAt: new Date(), updatedAt: new Date(), + } as never); + + const body = await (await get(buildApp())).json() as { + data: { youChoose: Array<{ id: string; channels: Record }> }; + }; + expect(body.data.youChoose.find((r) => r.id === MUTABLE)!.channels.email).toBe('on'); + }); + + 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/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/notifications/screen-model.spec.ts b/tests/unit/notifications/screen-model.spec.ts new file mode 100644 index 000000000..8697b84a4 --- /dev/null +++ b/tests/unit/notifications/screen-model.spec.ts @@ -0,0 +1,98 @@ +/** + * §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 noChoices = new Map(); + +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', 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([]); + // The reader must be able to see BOTH questions answered. + expect(m.alwaysSent.length).toBeGreaterThan(0); + expect(m.youChoose.length).toBeGreaterThan(0); + }); + + 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('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', () => { + // 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 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. + 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 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'); + }); +}); 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'); + }); +}); 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(), 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/reports/public-report-endpoint.spec.ts b/tests/unit/reports/public-report-endpoint.spec.ts index 77936cddc..be41a16fa 100644 --- a/tests/unit/reports/public-report-endpoint.spec.ts +++ b/tests/unit/reports/public-report-endpoint.spec.ts @@ -27,6 +27,9 @@ describe('GET /api/public/report/:tenant/:id — ③-A.1', () => { resolveToken: ReturnType, getReportData = vi.fn().mockResolvedValue({ inspectionId: 'insp1' }), resolveAgentViewToken = vi.fn().mockResolvedValue(null), + // Option A — a recipient read resolves the latest PUBLISHED version and + // renders its snapshot. `null` is the draft case: no version row yet. + getLatestPublished = vi.fn().mockResolvedValue({ versionNumber: 7 }), ) { // The publish gate added to reportRoute runs drizzle(c.env.DB).select()...get() // on the resolved (client/legacy) path. These cases all use published @@ -37,11 +40,15 @@ describe('GET /api/public/report/:tenant/:id — ③-A.1', () => { const app = new OpenAPIHono(); app.use('*', async (c, next) => { (c as unknown as { env: Record }).env = { DB: {} }; - c.set('services', { portalAccess: { resolveToken }, inspection: { getReportData, resolveAgentViewToken } } as unknown as HonoConfig['Variables']['services']); + c.set('services', { + portalAccess: { resolveToken }, + inspection: { getReportData, resolveAgentViewToken }, + reportVersion: { getLatestPublished }, + } as unknown as HonoConfig['Variables']['services']); await next(); }); app.route('/api/public', publicReportRoutes); - return { app, getReportData }; + return { app, getReportData, getLatestPublished }; } it('404 when no token', async () => { @@ -63,7 +70,53 @@ 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)); + // 5th arg: the pinned version. A recipient read is EXACTLY the read that + // must be served a frozen snapshot — this assertion used to require + // `undefined` here, which is the behaviour option A was chosen to + // replace: the delivered report tracked live state and silently changed + // under the client after it was signed. + expect(getReportData).toHaveBeenCalledWith('insp1', 't9', expect.any(Function), expect.any(Object), 7); + }); + + /** + * OPTION A — the assertion the plan named and said nobody writes. + * + * "publish a report, change the credential, re-read the published report — + * the badge must be the old one." That is a snapshot READ, and the everyday + * web link was the one path still resolving live. These pin the routing + * decision; the overlay itself is covered where the snapshot is applied. + */ + it('falls back to LIVE when the report has no published version yet', async () => { + // A draft has no version row. Pinning `undefined` here is what lets an + // unpublished report render at all. + const { app, getReportData } = buildApp( + vi.fn().mockResolvedValue(tokenRow()), + undefined, undefined, + vi.fn().mockResolvedValue(null), + ); + const res = await app.request('/api/public/report/t/insp1?token=tok'); + expect(res.status).toBe(200); + expect(getReportData).toHaveBeenCalledWith('insp1', 't1', expect.any(Function), expect.any(Object), undefined); + }); + + it('pins whatever version is latest, not a hard-coded one', async () => { + // Guards the obvious wrong fix: pinning v1 forever, so every amendment + // after the first publish would be invisible to the client. + const { app, getReportData } = buildApp( + vi.fn().mockResolvedValue(tokenRow()), + undefined, undefined, + vi.fn().mockResolvedValue({ versionNumber: 3 }), + ); + await app.request('/api/public/report/t/insp1?token=tok'); + expect(getReportData).toHaveBeenCalledWith('insp1', 't1', expect.any(Function), expect.any(Object), 3); + }); + + it('resolves the version against the TOKEN tenant, never the URL tenant', async () => { + // Same rule the read itself follows. A URL-supplied tenant reaching this + // lookup would be a cross-tenant read of another company's versions. + const { app, getLatestPublished } = buildApp(vi.fn().mockResolvedValue(tokenRow())); + await app.request('/api/public/report/WRONG-TENANT/insp1?token=tok'); + expect(getLatestPublished).toHaveBeenCalledWith('t1', 'insp1'); }); it('200 with report data + queries by the token tenantId (not the URL)', async () => { @@ -73,7 +126,8 @@ 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)); + // 5th arg: the recipient's read is pinned to the latest published version. + expect(getReportData).toHaveBeenCalledWith('insp1', 't1', expect.any(Function), expect.any(Object), 7); }); }); diff --git a/tests/unit/reports/report-access-gate.spec.ts b/tests/unit/reports/report-access-gate.spec.ts index aec310cd7..32f45ec7a 100644 --- a/tests/unit/reports/report-access-gate.spec.ts +++ b/tests/unit/reports/report-access-gate.spec.ts @@ -1,6 +1,6 @@ // tests/unit/report-access-gate.spec.ts import { describe, it, expect, beforeEach, afterEach, beforeAll, vi } from 'vitest'; -import { publicReportAccessAllowed } from '../../../server/lib/report-access'; +import { publicReportAccessAllowed, shouldPinLatestPublished } from '../../../server/lib/report-access'; describe('publicReportAccessAllowed', () => { it('client token: allowed only when published', () => { @@ -98,6 +98,10 @@ describe('GET /api/public/report/:tenant/:id — publish gate', () => { c.set('services', { portalAccess: { resolveToken }, inspection: { getReportData, resolveAgentViewToken }, + // Option A resolves the latest published version on the recipient + // track. `null` = no version row, which keeps these cases about + // the PUBLISH GATE rather than about snapshot selection. + reportVersion: { getLatestPublished: vi.fn().mockResolvedValue(null) }, } as unknown as HonoConfig['Variables']['services']); if (opts.withKeyring) c.set('keyringPromise', Promise.resolve(keyring)); await next(); @@ -131,7 +135,11 @@ 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)); + // 5th arg: `undefined` HERE because this harness seeds no version row — + // not because a recipient read is meant to be live. Option A pins that + // read to the latest published version; public-report-endpoint.spec.ts + // owns that assertion. + expect(getReportData).toHaveBeenCalledWith(INSP_ID, TENANT_ID, expect.any(Function), expect.any(Object), undefined); }); it('owner-preview bypasses the gate (200 even when report_status=in_progress)', async () => { @@ -146,7 +154,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); }); }); @@ -317,3 +330,35 @@ describe('GET /api/public/verify/report/:token — reflects current publish stat expect(res.status).toBe(403); }); }); + +/** + * Which reads get the FROZEN snapshot (option A). + * + * The routing decision, isolated from the endpoint. It is deliberately not the + * same shape as the access gate above: that one lets render-token and owner + * traffic THROUGH, this one keeps both on live data, for two different reasons. + */ +describe('shouldPinLatestPublished', () => { + it('pins the recipient track — the read that must not change after delivery', () => { + expect(shouldPinLatestPublished({ renderMode: false, ownerPreview: false, tokenPinnedVersion: undefined })).toBe(true); + }); + + it('leaves OWNER PREVIEW live, so the author can see unpublished edits', () => { + // Pinning here would make the preview show the last published state and + // silently hide everything typed since — on the surface whose only job + // is showing work in progress. + expect(shouldPinLatestPublished({ renderMode: false, ownerPreview: true, tokenPinnedVersion: undefined })).toBe(false); + }); + + it('never overrides a version the render token already named', () => { + // The verify page materialises ONE specific version. Replacing it with + // "the latest" would hand back a different document than the hash the + // verifier is checking against. + expect(shouldPinLatestPublished({ renderMode: true, ownerPreview: false, tokenPinnedVersion: 2 })).toBe(false); + expect(shouldPinLatestPublished({ renderMode: true, ownerPreview: false, tokenPinnedVersion: undefined })).toBe(false); + }); + + it('a token version wins even if the caller looks like a recipient', () => { + expect(shouldPinLatestPublished({ renderMode: false, ownerPreview: false, tokenPinnedVersion: 1 })).toBe(false); + }); +}); 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..254b59d43 --- /dev/null +++ b/tests/unit/reports/report-pinned-version-read.spec.ts @@ -0,0 +1,185 @@ +/** + * 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 { pinnedLead, type Snapshot } from '../../../server/lib/version-diff'; +import { primaryLicenseOf } from '../../../server/lib/credentials/primary'; + +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); + }); +}); + +/** + * Who a pinned read credits, and with what. + * + * 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('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(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(pinnedLead(snap())).toBeNull(); + }); + + 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: this must not be null, or the `?? live` fallback fires. + const lead = pinnedLead(snap([ + { userId: 'u1', name: 'Dana', role: 'lead', credentials: [] }, + ])); + expect(lead).not.toBeNull(); + expect(lead!.credentials).toEqual([]); + }); + + 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(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 lead = pinnedLead(snap([ + { userId: 'u2', name: 'Sam', role: 'helper', credentials: [cred('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('returns null for an empty list, so the caller omits the line', () => { + expect(primaryLicenseOf([])).toBeNull(); + }); +}); diff --git a/tests/unit/reports/report-signature-payload.spec.ts b/tests/unit/reports/report-signature-payload.spec.ts index 6ab657fb6..8b9a252f6 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` carries no licence column. + // 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, 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); + }); +}); 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 () => { diff --git a/tests/workers/cmd-consumer.spec.ts b/tests/workers/cmd-consumer.spec.ts index c01839d09..0ebdd0115 100644 --- a/tests/workers/cmd-consumer.spec.ts +++ b/tests/workers/cmd-consumer.spec.ts @@ -48,7 +48,7 @@ async function seedSchema(): Promise { "CREATE TABLE IF NOT EXISTS sync_outbox (id TEXT PRIMARY KEY, event_type TEXT NOT NULL, payload TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', attempts INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, last_tried_at INTEGER, last_error TEXT);", ); await b.DB.exec( - "CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, tenant_id TEXT, email TEXT NOT NULL, password_hash TEXT NOT NULL, name TEXT, phone TEXT, license_number TEXT, photo_url TEXT, default_signature_base64 TEXT, is_signature_enabled INTEGER NOT NULL DEFAULT true, bio TEXT, service_areas TEXT, slug TEXT, role TEXT NOT NULL DEFAULT 'admin', google_refresh_token TEXT, google_calendar_id TEXT, google_access_token TEXT, google_token_expiry INTEGER, locale TEXT, onboarding_state TEXT, created_at INTEGER NOT NULL, totp_secret TEXT, is_totp_enabled INTEGER NOT NULL DEFAULT false, totp_recovery_codes TEXT, totp_verified_at INTEGER, is_referral_notification_enabled INTEGER NOT NULL DEFAULT true, is_report_notification_enabled INTEGER NOT NULL DEFAULT true, is_paid_notification_enabled INTEGER NOT NULL DEFAULT false, last_active_at INTEGER, mentor_id TEXT, assigned_section_ids TEXT NOT NULL DEFAULT '[]', expires_at INTEGER, signup_role TEXT, deleted_at INTEGER, terms_accepted TEXT, permission_overrides TEXT, timezone TEXT);", + "CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, tenant_id TEXT, email TEXT NOT NULL, password_hash TEXT NOT NULL, name TEXT, phone TEXT, photo_url TEXT, default_signature_base64 TEXT, is_signature_enabled INTEGER NOT NULL DEFAULT true, bio TEXT, service_areas TEXT, slug TEXT, role TEXT NOT NULL DEFAULT 'admin', google_refresh_token TEXT, google_calendar_id TEXT, google_access_token TEXT, google_token_expiry INTEGER, locale TEXT, onboarding_state TEXT, created_at INTEGER NOT NULL, totp_secret TEXT, is_totp_enabled INTEGER NOT NULL DEFAULT false, totp_recovery_codes TEXT, totp_verified_at INTEGER, is_referral_notification_enabled INTEGER NOT NULL DEFAULT true, is_report_notification_enabled INTEGER NOT NULL DEFAULT true, is_paid_notification_enabled INTEGER NOT NULL DEFAULT false, last_active_at INTEGER, mentor_id TEXT, assigned_section_ids TEXT NOT NULL DEFAULT '[]', expires_at INTEGER, signup_role TEXT, deleted_at INTEGER, terms_accepted TEXT, permission_overrides TEXT, timezone TEXT);", ); await b.DB.exec( 'CREATE TABLE IF NOT EXISTS processed_cmd_events (event_id TEXT PRIMARY KEY, cmd_type TEXT NOT NULL, processed_at INTEGER NOT NULL);', diff --git a/tests/workers/cmd-fixtures.spec.ts b/tests/workers/cmd-fixtures.spec.ts index 552f2f616..b9e8cab23 100644 --- a/tests/workers/cmd-fixtures.spec.ts +++ b/tests/workers/cmd-fixtures.spec.ts @@ -36,7 +36,7 @@ describe('cmd golden fixtures — consumer can apply every fixture (A-21)', () = // Full users DDL (mirrors cmd-consumer.spec.ts) — the replyto fixture // carries credentials, and the drizzle insert binds every column. await b.DB.exec( - "CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, tenant_id TEXT, email TEXT NOT NULL, password_hash TEXT NOT NULL, name TEXT, phone TEXT, license_number TEXT, photo_url TEXT, default_signature_base64 TEXT, is_signature_enabled INTEGER NOT NULL DEFAULT true, bio TEXT, service_areas TEXT, slug TEXT, role TEXT NOT NULL DEFAULT 'admin', google_refresh_token TEXT, google_calendar_id TEXT, google_access_token TEXT, google_token_expiry INTEGER, locale TEXT, onboarding_state TEXT, created_at INTEGER NOT NULL, totp_secret TEXT, is_totp_enabled INTEGER NOT NULL DEFAULT false, totp_recovery_codes TEXT, totp_verified_at INTEGER, is_referral_notification_enabled INTEGER NOT NULL DEFAULT true, is_report_notification_enabled INTEGER NOT NULL DEFAULT true, is_paid_notification_enabled INTEGER NOT NULL DEFAULT false, last_active_at INTEGER, mentor_id TEXT, assigned_section_ids TEXT NOT NULL DEFAULT '[]', expires_at INTEGER, signup_role TEXT, deleted_at INTEGER, terms_accepted TEXT, permission_overrides TEXT, timezone TEXT);", + "CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, tenant_id TEXT, email TEXT NOT NULL, password_hash TEXT NOT NULL, name TEXT, phone TEXT, photo_url TEXT, default_signature_base64 TEXT, is_signature_enabled INTEGER NOT NULL DEFAULT true, bio TEXT, service_areas TEXT, slug TEXT, role TEXT NOT NULL DEFAULT 'admin', google_refresh_token TEXT, google_calendar_id TEXT, google_access_token TEXT, google_token_expiry INTEGER, locale TEXT, onboarding_state TEXT, created_at INTEGER NOT NULL, totp_secret TEXT, is_totp_enabled INTEGER NOT NULL DEFAULT false, totp_recovery_codes TEXT, totp_verified_at INTEGER, is_referral_notification_enabled INTEGER NOT NULL DEFAULT true, is_report_notification_enabled INTEGER NOT NULL DEFAULT true, is_paid_notification_enabled INTEGER NOT NULL DEFAULT false, last_active_at INTEGER, mentor_id TEXT, assigned_section_ids TEXT NOT NULL DEFAULT '[]', expires_at INTEGER, signup_role TEXT, deleted_at INTEGER, terms_accepted TEXT, permission_overrides TEXT, timezone TEXT);", ); await b.DB.exec('CREATE TABLE IF NOT EXISTS processed_cmd_events (event_id TEXT PRIMARY KEY, cmd_type TEXT NOT NULL, processed_at INTEGER NOT NULL);'); await b.DB.exec('CREATE TABLE IF NOT EXISTS parked_cmd_events (id TEXT PRIMARY KEY, envelope TEXT NOT NULL, reason TEXT NOT NULL, received_at INTEGER NOT NULL);'); diff --git a/tests/workers/migration-replay.ts b/tests/workers/migration-replay.ts new file mode 100644 index 000000000..e874455e6 --- /dev/null +++ b/tests/workers/migration-replay.ts @@ -0,0 +1,65 @@ +/** + * Replay the real migration SQL into a workerd test D1. + * + * Shared because three specs had their own copy of it and all three broke on the + * same migration the same day — the duplication did not drift, it was uniformly + * wrong, which is the harder kind to notice. + * + * `D1Database.exec()` treats each NEWLINE as a statement boundary, so a + * multi-line statement has to be flattened onto one line before it is sent. + * That flattening is what makes comment handling load-bearing: the old version + * dropped only lines that START with `--`, so a TRAILING comment survived, and + * once everything was joined onto a single line it commented out the remainder + * of the statement. The migration was valid SQL; the replay was not. + * + * INSERT INTO … SELECT + * 'Licensed home inspector', -- the string the old renderer hard-coded + * u.license_number, … + * + * flattened to `INSERT INTO … SELECT 'Licensed home inspector', -- the string …` + * and D1 answered `incomplete input`. + */ + +/** + * Strip a `--` line comment, but only when the `--` is not inside a string + * literal — `'has -- inside'` is data, not a comment. SQL escapes a quote by + * doubling it, which needs no special case here: the second quote of a `''` + * pair simply re-opens the string, leaving the in/out state correct. + */ +export function stripSqlLineComment(line: string): string { + let inString = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === "'") inString = !inString; + else if (!inString && ch === '-' && line[i + 1] === '-') return line.slice(0, i); + } + return line; +} + +/** Flatten one migration statement into the single line `exec()` requires. */ +export function flattenStatement(stmt: string): string { + return stmt + .split('\n') + .map(stripSqlLineComment) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Apply every migration in filename order, exactly as production applies them. + * `sqlByPath` comes from the caller's own `import.meta.glob('…/migrations/*.sql', + * { query: '?raw', eager: true })` — the pool's bundler inlines the bodies, and + * the glob has to be literal at each call site for that to happen. + */ +export async function applyMigrations( + db: D1Database, + sqlByPath: Record, +): Promise { + for (const file of Object.keys(sqlByPath).sort()) { + for (const stmt of sqlByPath[file]!.split('--> statement-breakpoint')) { + const flattened = flattenStatement(stmt); + if (flattened) await db.exec(flattened); + } + } +} diff --git a/tests/workers/reinspections.spec.ts b/tests/workers/reinspections.spec.ts index 09ca657fe..9dd460f46 100644 --- a/tests/workers/reinspections.spec.ts +++ b/tests/workers/reinspections.spec.ts @@ -27,33 +27,23 @@ import { PortalAccessService } from '../../server/services/portal-access.service import { seedRoleProfiles } from '../../server/services/seed/seed-role-profiles'; import publicReportRoutes from '../../server/api/public-report'; import type { HonoConfig } from '../../server/types/hono'; +import { applyMigrations as replayMigrations } from './migration-replay'; const b = env as unknown as { DB: D1Database }; const KEY_SECRET = 'test-key-encryption-secret-0123456789'; // Replay every migration .sql exactly as production applies them. Vite (the // pool's bundler) inlines the file bodies via import.meta.glob ?raw. +// The pool's bundler inlines the migration bodies via import.meta.glob ?raw; +// the glob must be literal HERE for that to happen, so the replay helper takes +// the map rather than doing the glob itself. const migrationSql = import.meta.glob('../../migrations/*.sql', { query: '?raw', import: 'default', eager: true, }) as Record; -async function applyMigrations(): Promise { - const files = Object.keys(migrationSql).sort(); - for (const file of files) { - const sql = migrationSql[file]!; - for (const stmt of sql.split('--> statement-breakpoint')) { - const cleaned = stmt - .split('\n') - .filter((line) => !line.trim().startsWith('--')) - .join(' ') - .replace(/\s+/g, ' ') - .trim(); - if (cleaned) await b.DB.exec(cleaned); - } - } -} +const applyMigrations = () => replayMigrations(b.DB, migrationSql); function reportVersionService(): ReportVersionService { return new ReportVersionService(b.DB, KEY_SECRET); @@ -72,6 +62,12 @@ function reportApp() { c.set('services', { inspection: inspectionService(), portalAccess: portalAccessService(), + // The recipient read resolves the latest published version and + // renders ITS snapshot, so the route needs this service. Registered + // here rather than guarded with `?.` in the route: a missing service + // is a DI misconfiguration, and optional-chaining it would turn a + // loud 500 into a report silently served from live tables. + reportVersion: reportVersionService(), } as unknown as HonoConfig['Variables']['services']); await next(); }); diff --git a/tests/workers/report-amendments.spec.ts b/tests/workers/report-amendments.spec.ts index fb65f197a..74b80ea17 100644 --- a/tests/workers/report-amendments.spec.ts +++ b/tests/workers/report-amendments.spec.ts @@ -32,6 +32,7 @@ import { ReportPdfService } from '../../server/services/report-pdf.service'; import { SigningKeyService } from '../../server/services/signing-key.service'; import publicReportRoutes from '../../server/api/public-report'; import type { HonoConfig } from '../../server/types/hono'; +import { applyMigrations as replayMigrations } from './migration-replay'; const b = env as unknown as { DB: D1Database }; const KEY_SECRET = 'test-key-encryption-secret-0123456789'; @@ -44,33 +45,16 @@ const PUBLISHER = 'user-a1'; // Replay every migration .sql exactly as production applies them. Vite (the // pool's bundler) inlines the file bodies via import.meta.glob ?raw, so this // works inside workerd where node:fs is unavailable. +// The pool's bundler inlines the migration bodies via import.meta.glob ?raw; +// the glob must be literal HERE for that to happen, so the replay helper takes +// the map rather than doing the glob itself. const migrationSql = import.meta.glob('../../migrations/*.sql', { query: '?raw', import: 'default', eager: true, }) as Record; -async function applyMigrations(): Promise { - const files = Object.keys(migrationSql).sort(); - for (const file of files) { - const sql = migrationSql[file]!; - for (const stmt of sql.split('--> statement-breakpoint')) { - // Strip whole-line `--` comments: D1.exec rejects a statement whose - // text reduces to a comment, and a leading comment line preceding - // the DDL would otherwise swallow the statement. - // D1.exec is line-oriented (it splits on newlines), so strip - // whole-line `--` comments AND collapse the remaining DDL onto a - // single line before exec. - const cleaned = stmt - .split('\n') - .filter((line) => !line.trim().startsWith('--')) - .join(' ') - .replace(/\s+/g, ' ') - .trim(); - if (cleaned) await b.DB.exec(cleaned); - } - } -} +const applyMigrations = () => replayMigrations(b.DB, migrationSql); function reportVersionService(): ReportVersionService { return new ReportVersionService(b.DB, KEY_SECRET); diff --git a/tests/workers/word-export-consumer.spec.ts b/tests/workers/word-export-consumer.spec.ts index 00017c052..c95b4f809 100644 --- a/tests/workers/word-export-consumer.spec.ts +++ b/tests/workers/word-export-consumer.spec.ts @@ -10,6 +10,7 @@ import * as schema from '../../server/lib/db/schema'; import { handleWordExportBatch } from '../../server/services/report-export-consumer'; import { ReportExportService } from '../../server/services/report-export.service'; import type { WordExportJob } from '../../server/lib/sync-events/word-export-job'; +import { applyMigrations as replayMigrations } from './migration-replay'; const b = env as unknown as { DB: D1Database; PHOTOS: R2Bucket }; @@ -65,27 +66,16 @@ function distinctLargeBytes(index: number, byteSize: number): Uint8Array { // Replay every migration .sql exactly as production applies them (mirrors // tests/workers/report-amendments.spec.ts). +// The pool's bundler inlines the migration bodies via import.meta.glob ?raw; +// the glob must be literal HERE for that to happen, so the replay helper takes +// the map rather than doing the glob itself. const migrationSql = import.meta.glob('../../migrations/*.sql', { query: '?raw', import: 'default', eager: true, }) as Record; -async function applyMigrations(): Promise { - const files = Object.keys(migrationSql).sort(); - for (const file of files) { - const sql = migrationSql[file]!; - for (const stmt of sql.split('--> statement-breakpoint')) { - const cleaned = stmt - .split('\n') - .filter((line) => !line.trim().startsWith('--')) - .join(' ') - .replace(/\s+/g, ' ') - .trim(); - if (cleaned) await b.DB.exec(cleaned); - } - } -} +const applyMigrations = () => replayMigrations(b.DB, migrationSql); async function seedCommercialInspection(): Promise { const db = drizzle(b.DB); diff --git a/vite.config.ts b/vite.config.ts index 4e15e0d0d..ffba9ed16 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -47,6 +47,25 @@ function konvaSsrStub(): Plugin { } export default defineConfig({ + /** + * MINIFY THE WORKER. Vite turns minification off for SSR builds by default, + * on the reasonable assumption that server output runs in Node where bytes do + * not matter. Here the "SSR" build IS the deployed artifact, and Workers Free + * caps the script at 3 MiB gzipped — so the default shipped the worker as + * 177k lines of unminified source, averaging 37 bytes a line. + * + * That is why the bundle sat at the ceiling while every diet attempt went + * looking for whole dependencies to remove: the largest single saving was not + * a dependency at all, it was a build flag nobody had set. + * + * `esbuild` (not terser) because the build already runs it and the difference + * between the two is a couple of percent against a saving measured in tens. + * Exported names — the `fetch`/`queue`/`scheduled` handlers and the + * `InspectionDocDO` class that `wrangler.jsonc` binds BY NAME — are preserved + * by esbuild's minifier; `check:bundle` and the DO binding both fail loudly if + * that ever stops being true. + */ + build: { minify: "esbuild" }, resolve: { alias: { "~": path.resolve(__dirname, "app"),