From cc332c3282e4b9db82e3f92fd4d07690e9645d74 Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 6 Jun 2026 21:51:54 +0800 Subject: [PATCH 01/19] fix(settings-sheet): strip time part from date when loading from BFF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DB stores dates as ISO datetimes (e.g. '2026-05-29T09:00:00'). This value was loaded verbatim into the form, causing two problems: 1. could not parse it (showed empty placeholder) 2. sanitizeSettingsPatch bypassed the date→ISO conversion, so the API received a datetime without a timezone suffix, failing z.string().datetime() Fix: strip the time component on load so the date picker gets 'YYYY-MM-DD', which sanitizeSettingsPatch then correctly converts to a Z-suffixed ISO string. Applied to closingDate too for consistency. --- app/components/editor/InspectionSettingsSheet.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/editor/InspectionSettingsSheet.tsx b/app/components/editor/InspectionSettingsSheet.tsx index 80245701..f40eb25d 100644 --- a/app/components/editor/InspectionSettingsSheet.tsx +++ b/app/components/editor/InspectionSettingsSheet.tsx @@ -90,8 +90,8 @@ export function InspectionSettingsSheet({ open, onClose, inspectionId, referralS const loadedTemplateId = (insp.templateId as string) || ""; templateIdAtOpen.current = loadedTemplateId; setForm({ - date: (insp.date as string) || "", - closingDate: (insp.closingDate as string) || "", + date: ((insp.date as string) || "").replace(/T.*/, ''), + closingDate: ((insp.closingDate as string) || "").replace(/T.*/, ''), inspectorId: (insp.inspectorId as string) || "", orderId: (insp.orderId as string) || "", referralSource: (insp.referralSource as string) || "", From 7161089282207e62563e3e074f06f7be608ec8a6 Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 6 Jun 2026 22:15:13 +0800 Subject: [PATCH 02/19] fix(team): read body.data.members not body.data for team list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api.team.members returns {data:{members:[...]}} — the loader was reading body.data (an object) and calling .filter() on it, causing a TypeError crash. Align with the BFF sheet route which correctly reads data?.members. --- app/routes/team.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/routes/team.tsx b/app/routes/team.tsx index 6268b1aa..9e37205b 100644 --- a/app/routes/team.tsx +++ b/app/routes/team.tsx @@ -25,9 +25,9 @@ export async function loader({ request, context }: Route.LoaderArgs) { try { const api = createApi(context, { token }); const res = await api.team.members.$get(); - const body = res.ok ? ((await res.json()) as Record) : { data: [] }; + const body = res.ok ? ((await res.json()) as unknown as { data?: { members?: Member[] } }) : { data: { members: [] as Member[] } }; return { - members: (body.data ?? []) as Member[], + members: (body.data?.members ?? []) as Member[], settings: {} as Record, }; } catch { From fa08973743cd570a485029c0380b790c641a7269 Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 6 Jun 2026 22:16:59 +0800 Subject: [PATCH 03/19] feat(team): wire InviteSeatModal to Invite Member button --- app/routes/team.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/routes/team.tsx b/app/routes/team.tsx index 9e37205b..e9cb9f64 100644 --- a/app/routes/team.tsx +++ b/app/routes/team.tsx @@ -4,6 +4,7 @@ import type { Route } from "./+types/team"; import { requireToken } from "~/lib/session.server"; import { createApi } from "~/lib/api-client.server"; import { SeatBanner } from "~/components/SeatBanner"; +import { InviteSeatModal } from "~/components/modals/InviteSeatModal"; import { useSessionContext } from "~/hooks/useSessionContext"; import { PageHeader, TabStrip, Card, Pill, Button, EmptyState } from "@core/shared-ui"; @@ -57,6 +58,9 @@ export default function TeamPage() { const { members } = useLoaderData(); const sessionCtx = useSessionContext(); const [activeTab, setActiveTab] = useState("active"); + const [inviteOpen, setInviteOpen] = useState(false); + + const leads = members.filter((m) => m.role === "lead").map((m) => ({ id: m.id, email: m.email })); const filtered = members.filter((m) => { if (activeTab === "active") return m.status !== "pending" && m.role !== "apprentice"; @@ -79,12 +83,14 @@ export default function TeamPage() { title="Workspace Team" meta={`${members.length} ${members.length === 1 ? "member" : "members"}`} actions={ - } /> + setInviteOpen(false)} leads={leads} /> + {filtered.length === 0 ? ( From 36949bad9d6b8fecf18953cb4bde2ae9bb6e0604 Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 6 Jun 2026 22:39:51 +0800 Subject: [PATCH 04/19] fix(bff): use valid pageSize=100 for template list in inspection-settings BFF pageSize: "200" fails Zod refine (valid: 12/25/50/100); the .catch(() => null) silently swallowed the 400 so the template dropdown was always empty. Co-Authored-By: Claude Sonnet 4.6 --- app/routes/resources/inspection-settings-sheet.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes/resources/inspection-settings-sheet.tsx b/app/routes/resources/inspection-settings-sheet.tsx index b3011175..241ce328 100644 --- a/app/routes/resources/inspection-settings-sheet.tsx +++ b/app/routes/resources/inspection-settings-sheet.tsx @@ -33,7 +33,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { const [inspRes, tplRes, membersRes] = await Promise.all([ api.inspections[":id"].$get({ param: { id: inspectionId } }, hdr).catch(() => null), - api.inspections.templates.$get({ query: { page: "1", pageSize: "200" } }, hdr).catch(() => null), + api.inspections.templates.$get({ query: { page: "1", pageSize: "100" } }, hdr).catch(() => null), api.team.members.$get({}, hdr).catch(() => null), ]); From 51cbde6e4cdf4dd3e7debf071996b1be9e270cf1 Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 6 Jun 2026 23:42:55 +0800 Subject: [PATCH 05/19] feat(search): lazy-load template picker + server-side search for templates & dashboard - TemplateCombobox: new reusable combobox with debounced search, cursor pagination (25/page), keyboard nav, load-more; replaces setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search templates..." + className="w-full h-8 pl-8 pr-3 text-[13px] bg-ih-bg-muted rounded border border-ih-border outline-none focus:border-ih-primary text-ih-fg-1 placeholder:text-ih-fg-4" + /> + + + + {/* List */} +
+ {isLoading ? ( +
Loading…
+ ) : results.length === 0 ? ( +
+ {query ? "No templates match your search" : "No templates found"} +
+ ) : ( + <> + {/* Clear selection option */} + {value && ( + + )} + {results.map((t, i) => ( + + ))} + + )} +
+ + {/* Load more */} + {hasMore && !isLoading && ( +
+ +
+ )} + + )} + + ); +} diff --git a/app/components/editor/InspectionSettingsSheet.tsx b/app/components/editor/InspectionSettingsSheet.tsx index f40eb25d..7bd8db4c 100644 --- a/app/components/editor/InspectionSettingsSheet.tsx +++ b/app/components/editor/InspectionSettingsSheet.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from "react"; import { useFetcher } from "react-router"; +import { TemplateCombobox } from "~/components/TemplateCombobox"; interface SettingsForm { date: string; @@ -225,13 +226,14 @@ export function InspectionSettingsSheet({ open, onClose, inspectionId, referralS
Template -
diff --git a/app/routes.ts b/app/routes.ts index d18af330..4eebb648 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -86,6 +86,8 @@ export default [ route("resources/publish-readiness", "routes/resources/publish-readiness.tsx"), route("resources/recent-inspections", "routes/resources/recent-inspections.tsx"), route("resources/team-members", "routes/resources/team-members.tsx"), + route("resources/template-search", "routes/resources/template-search.tsx"), + route("resources/inspection-search", "routes/resources/inspection-search.tsx"), layout("routes/auth-layout.tsx", [ // IA-6 — BFF resource route for advisory schedule-conflict detection. // Loaded via useFetcher; no UI rendered; must be inside the auth layout so diff --git a/app/routes/dashboard.tsx b/app/routes/dashboard.tsx index e35e523f..e83954fb 100644 --- a/app/routes/dashboard.tsx +++ b/app/routes/dashboard.tsx @@ -1,5 +1,6 @@ import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { useLoaderData, Link, useFetcher, useSearchParams, redirect } from "react-router"; +import type { InspectionSearchItem } from "~/routes/resources/inspection-search"; import type { Route } from "./+types/dashboard"; import { requireToken } from "~/lib/session.server"; import { createApi } from "~/lib/api-client.server"; @@ -417,6 +418,14 @@ export default function DashboardPage() { const [visiblePage, setVisiblePage] = useState(1); const sentinelRef = useRef(null); + /* ---- Server-side search (fetcher + state) ---- */ + const searchFetcher = useFetcher<{ inspections: InspectionSearchItem[]; hasMore: boolean; nextCursor: string | null }>(); + const [serverResults, setServerResults] = useState([]); + const [serverCursor, setServerCursor] = useState(null); + const [serverHasMore, setServerHasMore] = useState(false); + const searchDebounceRef = useRef | null>(null); + const isLoadMoreRef = useRef(false); + /* ---- Columns (persisted in localStorage) ---- */ const [visibleColumns, setVisibleColumns] = useState(() => { if (typeof window === "undefined") return DEFAULT_COLUMNS; @@ -533,6 +542,38 @@ export default function DashboardPage() { // Reset page when filters change useEffect(() => { setVisiblePage(1); }, [activeTab, activeFilter, activeTagFilter, searchQuery, filterDateFrom, filterDateTo, filterAgentId]); + // Debounce searchQuery → server-side search via BFF + useEffect(() => { + if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + if (!searchQuery.trim()) { + setServerResults([]); + setServerCursor(null); + setServerHasMore(false); + return; + } + searchDebounceRef.current = setTimeout(() => { + isLoadMoreRef.current = false; + setServerResults([]); + setServerCursor(null); + searchFetcher.load(`/resources/inspection-search?q=${encodeURIComponent(searchQuery.trim())}`); + }, 300); + return () => { if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); }; + }, [searchQuery]); // searchFetcher.load is stable; omitting it avoids infinite loop + + // Apply searchFetcher results (fresh search or load-more append) + useEffect(() => { + if (searchFetcher.state !== "idle" || !searchFetcher.data) return; + const d = searchFetcher.data; + if (isLoadMoreRef.current) { + setServerResults(prev => [...prev, ...d.inspections]); + isLoadMoreRef.current = false; + } else { + setServerResults(d.inspections); + } + setServerHasMore(d.hasMore ?? false); + setServerCursor(d.nextCursor ?? null); + }, [searchFetcher.state, searchFetcher.data]); + /* ---- IA-12: Onboarding steps ---- */ // siteNameSet: the session context always returns a non-null siteName // (falling back to 'OpenInspection' when not configured). If the value @@ -640,6 +681,13 @@ export default function DashboardPage() { URL.revokeObjectURL(url); }, [filteredInspections]); + /* ---- Server search load-more ---- */ + const handleSearchLoadMore = () => { + if (!serverHasMore || !serverCursor || !searchQuery.trim()) return; + isLoadMoreRef.current = true; + searchFetcher.load(`/resources/inspection-search?q=${encodeURIComponent(searchQuery.trim())}&cursor=${encodeURIComponent(serverCursor)}`); + }; + /* ---- Status transition ---- */ const transitionStatus = (id: string, status: string) => { fetcher.submit({ intent: "status", id, status }, { method: "post" }); @@ -939,21 +987,43 @@ export default function DashboardPage() { })} ) : ( - /* Flat filtered view */ - -
- - {filteredInspections.length} result{filteredInspections.length !== 1 ? "s" : ""} - -
-
- {paginatedList.map((insp) => ( - - ))} -
- {/* Infinite scroll sentinel */} - {hasMore &&
} - + /* Flat filtered view — server search when query active, client-side otherwise */ + (() => { + const isServerSearch = searchQuery.trim().length > 0; + const isSearching = isServerSearch && searchFetcher.state !== "idle"; + const displayList = isServerSearch ? (serverResults as unknown as Inspection[]) : paginatedList; + const displayCount = isServerSearch ? serverResults.length : filteredInspections.length; + return ( + +
+ + {isSearching ? "Searching…" : `${displayCount} result${displayCount !== 1 ? "s" : ""}`} + +
+
+ {!isSearching && displayList.map((insp) => ( + + ))} +
+ {isSearching && ( +
Searching…
+ )} + {/* Server search load-more */} + {isServerSearch && serverHasMore && !isSearching && ( +
+ +
+ )} + {/* Infinite scroll for non-search flat mode */} + {!isServerSearch && hasMore &&
} + + ); + })() )} {/* Wizard modal */} diff --git a/app/routes/resources/inspection-search.tsx b/app/routes/resources/inspection-search.tsx new file mode 100644 index 00000000..7a8ba5fb --- /dev/null +++ b/app/routes/resources/inspection-search.tsx @@ -0,0 +1,58 @@ +/** + * BFF resource route for dashboard inspection search: server-side full-text + * search across all inspections (not just the loaded bucket subset). + */ +import type { Route } from "./+types/inspection-search"; +import { getToken } from "~/lib/session.server"; +import { createApi } from "~/lib/api-client.server"; + +export interface InspectionSearchItem { + id: string; + address: string; + propertyAddress: string; + clientName: string | null; + clientEmail: string | null; + status: string; + date: string | null; +} + +export async function loader({ request, context }: Route.LoaderArgs) { + const token = await getToken(context, request); + if (!token) return { inspections: [] as InspectionSearchItem[], hasMore: false, nextCursor: null as string | null }; + + const url = new URL(request.url); + const q = url.searchParams.get("q") ?? ""; + const cursor = url.searchParams.get("cursor") ?? ""; + + const api = createApi(context, { token }); + const hdr = { headers: { "x-token-relay": "1" } } as const; + + const query: Record = { limit: "25" }; + if (q) query.search = q; + if (cursor) query.cursor = cursor; + + const res = await api.inspections.index.$get({ query }, hdr).catch(() => null); + + if (!res?.ok) return { inspections: [] as InspectionSearchItem[], hasMore: false, nextCursor: null as string | null }; + + const body = (await res.json()) as { + data?: Array<{ id: string; propertyAddress: string; clientName: string | null; clientEmail: string | null; status: string; date: string | null }>; + meta?: { nextCursor?: string | null }; + }; + + const inspections: InspectionSearchItem[] = (body.data ?? []).map(r => ({ + id: r.id, + address: r.propertyAddress, + propertyAddress: r.propertyAddress, + clientName: r.clientName ?? null, + clientEmail: r.clientEmail ?? null, + status: r.status, + date: r.date ?? null, + })); + + return { + inspections, + hasMore: !!body.meta?.nextCursor, + nextCursor: body.meta?.nextCursor ?? null, + }; +} diff --git a/app/routes/resources/template-search.tsx b/app/routes/resources/template-search.tsx new file mode 100644 index 00000000..dccde5e7 --- /dev/null +++ b/app/routes/resources/template-search.tsx @@ -0,0 +1,45 @@ +/** + * BFF resource route for TemplateCombobox: lazy-loads templates with optional + * search query and pagination, avoiding any direct client-side API fetches. + */ +import type { Route } from "./+types/template-search"; +import { getToken } from "~/lib/session.server"; +import { createApi } from "~/lib/api-client.server"; + +interface TemplateSummary { + id: string; + name: string; +} + +export async function loader({ request, context }: Route.LoaderArgs) { + const token = await getToken(context, request); + if (!token) return { templates: [] as TemplateSummary[], hasMore: false, page: 1, totalPages: 1 }; + + const url = new URL(request.url); + const q = url.searchParams.get("q") ?? ""; + const page = Math.max(1, parseInt(url.searchParams.get("page") ?? "1", 10)); + + const api = createApi(context, { token }); + const hdr = { headers: { "x-token-relay": "1" } } as const; + + const res = await api.inspections.templates.$get({ + query: { page: String(page), pageSize: "25", ...(q ? { q } : {}) }, + }, hdr).catch(() => null); + + if (!res?.ok) return { templates: [] as TemplateSummary[], hasMore: false, page: 1, totalPages: 1 }; + + const body = (await res.json()) as { + data?: TemplateSummary[]; + meta?: { page: number; totalPages: number }; + }; + + const templates = (body.data ?? []).map(t => ({ id: t.id, name: t.name })); + const meta = body.meta; + + return { + templates, + hasMore: meta ? meta.page < meta.totalPages : false, + page: meta?.page ?? 1, + totalPages: meta?.totalPages ?? 1, + }; +} diff --git a/app/routes/templates.tsx b/app/routes/templates.tsx index b4229a5b..1b593074 100644 --- a/app/routes/templates.tsx +++ b/app/routes/templates.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useEffect, useRef } from "react"; -import { useLoaderData, useFetcher, useNavigate, Link } from "react-router"; +import { useLoaderData, useFetcher, useNavigate, useSearchParams, Link } from "react-router"; import type { Route } from "./+types/templates"; import { requireToken } from "~/lib/session.server"; import { createApi } from "~/lib/api-client.server"; @@ -42,12 +42,13 @@ export async function loader({ request, context }: Route.LoaderArgs) { const url = new URL(request.url); const page = url.searchParams.get("page") ?? "1"; const pageSize = url.searchParams.get("pageSize") ?? "50"; + const q = url.searchParams.get("q") ?? ""; const api = createApi(context, { token }); // Best-effort /api/auth/me to read spectoraMappingSeen (same pattern as dashboard IA-12). // TODO(C-10): same hono/client collapse as auth.me — localized cast. const meGet = api.auth.me.$get as unknown as (args?: unknown) => Promise; const [res, meRes] = await Promise.all([ - api.inspections.templates.$get({ query: { page, pageSize } }), + api.inspections.templates.$get({ query: { page, pageSize, ...(q ? { q } : {}) } }), meGet().catch(() => null), ]); const body = res.ok @@ -62,11 +63,12 @@ export async function loader({ request, context }: Route.LoaderArgs) { }; spectoraMappingSeen = meBody.data?.user?.onboardingState?.spectoraMappingSeen === true; } - return { templates, meta, token, spectoraMappingSeen }; + return { templates, meta, q, token, spectoraMappingSeen }; } catch { return { templates: [] as Template[], meta: { total: 0, page: 1, pageSize: 50, totalPages: 1 }, + q: "", token: "", spectoraMappingSeen: false, }; @@ -179,14 +181,16 @@ function countItems(t: Template): number { /* ------------------------------------------------------------------ */ export default function TemplatesPage() { - const { templates, meta, spectoraMappingSeen: loaderMappingSeen } = useLoaderData(); + const { templates, meta, q: loaderQ, spectoraMappingSeen: loaderMappingSeen } = useLoaderData(); const fetcher = useFetcher(); const mappingFetcher = useFetcher(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const { setPage, setPageSize } = usePagination(); const [view, setView] = useState<"list" | "card">("list"); - const [searchQuery, setSearchQuery] = useState(""); + const [searchQuery, setSearchQuery] = useState(loaderQ); + const searchDebounceRef = useRef | null>(null); const [sortBy, setSortBy] = useState("name"); const [createOpen, setCreateOpen] = useState(false); const [importOpen, setImportOpen] = useState(false); @@ -200,6 +204,22 @@ export default function TemplatesPage() { const [mappingSeenOptimistic, setMappingSeenOptimistic] = useState(false); const spectoraMappingSeen = loaderMappingSeen || mappingSeenOptimistic; + // Debounced URL-based search: triggers loader re-run for server-side filtering + useEffect(() => { + if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + searchDebounceRef.current = setTimeout(() => { + const params = new URLSearchParams(searchParams); + if (searchQuery) { + params.set("q", searchQuery); + } else { + params.delete("q"); + } + params.delete("page"); // reset to page 1 on new search + navigate(`?${params}`, { replace: true }); + }, 350); + return () => { if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); }; + }, [searchQuery]); // navigate/searchParams are stable refs; omitting avoids re-trigger loop + // Navigate to newly created/duplicated template. const fetcherData = fetcher.data as Record | undefined; if (fetcherData?.ok && fetcherData?.newId && typeof fetcherData.newId === "string") { @@ -223,20 +243,9 @@ export default function TemplatesPage() { } }, [fetcherData, spectoraMappingSeen]); - /* ---- Filter + Sort ---- */ + /* ---- Sort (search filtering is now server-side via URL ?q=) ---- */ const filtered = useMemo(() => { - let list = [...templates]; - - // Search - if (searchQuery) { - const q = searchQuery.toLowerCase(); - list = list.filter((t) => - t.name.toLowerCase().includes(q) || - (t.description || "").toLowerCase().includes(q), - ); - } - - // Sort + const list = [...templates]; list.sort((a, b) => { switch (sortBy) { case "name": return a.name.localeCompare(b.name); @@ -245,9 +254,8 @@ export default function TemplatesPage() { default: return 0; } }); - return list; - }, [templates, searchQuery, sortBy]); + }, [templates, sortBy]); const imported = templates.filter((t) => t.marketplaceTemplateId).length; const withUpdates = templates.filter((t) => t.upstreamUpdateAvailable).length; diff --git a/server/api/inspections.ts b/server/api/inspections.ts index 19d721a1..e06d6b3b 100644 --- a/server/api/inspections.ts +++ b/server/api/inspections.ts @@ -145,7 +145,7 @@ const listTemplatesRoute = createRoute(withMcpMetadata({ tags: ["inspections", "templates"], summary: "List inspection templates (paginated)", description: "Paginated list of inspection templates for the tenant.", - request: { query: paginationQuerySchema }, + request: { query: paginationQuerySchema.extend({ q: z.string().optional().describe('Filter templates by name (case-insensitive substring match)') }) }, responses: { 200: { content: { @@ -1893,13 +1893,18 @@ export const inspectionsRoutes = createApiRouter() }, 200); }) .openapi(listTemplatesRoute, async (c) => { - const q = c.req.valid('query'); + const queryParams = c.req.valid('query'); const service = c.var.services.template; - const { rows, total } = await service.listTemplates(c.get('tenantId'), q); + const { page, pageSize, q } = queryParams; + const { rows, total } = await service.listTemplates(c.get('tenantId'), { + ...(page !== undefined ? { page } : {}), + ...(pageSize !== undefined ? { pageSize } : {}), + ...(q !== undefined ? { q } : {}), + }); return c.json({ success: true, data: rows, - meta: buildMeta({ total, page: q.page, pageSize: q.pageSize }), + meta: buildMeta({ total, page: queryParams.page, pageSize: queryParams.pageSize }), }, 200); }) .openapi(listTemplateDuplicatesRoute, async (c) => { diff --git a/server/services/template.service.ts b/server/services/template.service.ts index 7196a938..269ce76f 100644 --- a/server/services/template.service.ts +++ b/server/services/template.service.ts @@ -1,5 +1,5 @@ import { drizzle } from 'drizzle-orm/d1'; -import { eq, and, desc, sql } from 'drizzle-orm'; +import { eq, and, desc, sql, like } from 'drizzle-orm'; import { templates, inspections } from '../lib/db/schema'; import { Errors } from '../lib/errors'; import { TemplateSchemaV2Schema } from '../lib/validations/template.schema'; @@ -58,20 +58,23 @@ export class TemplateService { /** * Lists all templates for a tenant. */ - async listTemplates(tenantId: string, opts: { page?: number; pageSize?: number } = {}) { - const { page = 1, pageSize = 50 } = opts; + async listTemplates(tenantId: string, opts: { page?: number; pageSize?: number; q?: string } = {}) { + const { page = 1, pageSize = 50, q } = opts; const db = this.getDrizzle(); + const baseWhere = eq(templates.tenantId, tenantId); + const where = q?.trim() ? and(baseWhere, like(templates.name, `%${q.trim()}%`)) : baseWhere; + const totalRow = await db .select({ c: sql`count(*)` }) .from(templates) - .where(eq(templates.tenantId, tenantId)) + .where(where) .get(); const total = totalRow?.c ?? 0; const rows = await db.select({ id: templates.id, name: templates.name, version: templates.version, schema: templates.schema }) .from(templates) - .where(eq(templates.tenantId, tenantId)) + .where(where) .orderBy(desc(templates.createdAt)) .limit(pageSize) .offset((page - 1) * pageSize) From e0e0a43889b640eea1f599098b8870782c54bad7 Mon Sep 17 00:00:00 2001 From: important-new Date: Mon, 8 Jun 2026 22:25:42 +0800 Subject: [PATCH 06/19] =?UTF-8?q?feat(sms):=20migration=200025=20=E2=80=94?= =?UTF-8?q?=20channels/sms=5Fbody,=20recipient=20rename,=20consent=20table?= =?UTF-8?q?s,=20sms=5Fmode,=20Twilio=20secret=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/0025_grey_nitro.sql | 25 + migrations/meta/0025_snapshot.json | 8064 +++++++++++++++++++++++++ migrations/meta/_journal.json | 7 + server/api/secrets.ts | 7 + server/lib/db/schema/compliance.ts | 25 + server/lib/db/schema/index.ts | 3 +- server/lib/db/schema/inspection.ts | 15 +- server/lib/db/schema/tenant.ts | 4 + server/services/automation.service.ts | 6 +- server/services/event.service.ts | 4 +- tests/unit/sms-schema.spec.ts | 14 + 11 files changed, 8165 insertions(+), 9 deletions(-) create mode 100644 migrations/0025_grey_nitro.sql create mode 100644 migrations/meta/0025_snapshot.json create mode 100644 tests/unit/sms-schema.spec.ts diff --git a/migrations/0025_grey_nitro.sql b/migrations/0025_grey_nitro.sql new file mode 100644 index 00000000..1a482245 --- /dev/null +++ b/migrations/0025_grey_nitro.sql @@ -0,0 +1,25 @@ +CREATE TABLE `sms_consent_log` ( + `id` text PRIMARY KEY NOT NULL, + `tenant_id` text NOT NULL, + `contact_id` text NOT NULL, + `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 +); +--> statement-breakpoint +CREATE INDEX `idx_sms_consent_contact` ON `sms_consent_log` (`tenant_id`,`contact_id`,`created_at`);--> statement-breakpoint +CREATE TABLE `sms_disclosure_versions` ( + `version` integer PRIMARY KEY NOT NULL, + `text` text NOT NULL, + `published_at` integer NOT NULL +); +--> statement-breakpoint +ALTER TABLE `automation_logs` RENAME COLUMN `recipient_email` TO `recipient`;--> statement-breakpoint +ALTER TABLE `automation_logs` ADD `channel` text DEFAULT 'email' NOT NULL;--> statement-breakpoint +ALTER TABLE `automations` ADD `channels` text DEFAULT '["email"]' NOT NULL;--> statement-breakpoint +ALTER TABLE `automations` ADD `sms_body` text;--> statement-breakpoint +ALTER TABLE `tenant_configs` ADD `sms_mode` text DEFAULT 'platform' NOT NULL; \ No newline at end of file diff --git a/migrations/meta/0025_snapshot.json b/migrations/meta/0025_snapshot.json new file mode 100644 index 00000000..e116ede7 --- /dev/null +++ b/migrations/meta/0025_snapshot.json @@ -0,0 +1,8064 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "ac6b4811-3079-4b94-8fb6-af8df96906a0", + "prevId": "4cdbb422-ddfd-4fcb-9d23-48debeacf5ee", + "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": false, + "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 + } + }, + "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": {} + }, + "apprentice_reviews": { + "name": "apprentice_reviews", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "apprentice_id": { + "name": "apprentice_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mentor_id": { + "name": "mentor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "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 + }, + "field": { + "name": "field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "proposed_value": { + "name": "proposed_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "decision_value": { + "name": "decision_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision_at": { + "name": "decision_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "submitted_at": { + "name": "submitted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "apprentice_reviews_mentor_status_idx": { + "name": "apprentice_reviews_mentor_status_idx", + "columns": [ + "tenant_id", + "mentor_id", + "status" + ], + "isUnique": false + }, + "apprentice_reviews_inspection_item_idx": { + "name": "apprentice_reviews_inspection_item_idx", + "columns": [ + "inspection_id", + "item_id" + ], + "isUnique": false + }, + "apprentice_reviews_apprentice_idx": { + "name": "apprentice_reviews_apprentice_idx", + "columns": [ + "apprentice_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "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": true, + "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 + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'email'" + }, + "send_at": { + "name": "send_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "text", + "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 + } + }, + "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 + } + }, + "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": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": true, + "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 + }, + "conditions": { + "name": "conditions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'email'" + }, + "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 + }, + "active": { + "name": "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 + } + }, + "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 + } + }, + "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" + } + }, + "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": {} + }, + "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 + }, + "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 + }, + "disabled": { + "name": "disabled", + "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_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_bookings": { + "name": "concierge_bookings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "confirmation_token": { + "name": "confirmation_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invite_token": { + "name": "invite_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slot_start": { + "name": "slot_start", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slot_end": { + "name": "slot_end", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_name": { + "name": "contact_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_email": { + "name": "contact_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_phone": { + "name": "contact_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "concierge_bookings_confirmation_token_unique": { + "name": "concierge_bookings_confirmation_token_unique", + "columns": [ + "confirmation_token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "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": {} + }, + "concierge_invites": { + "name": "concierge_invites", + "columns": { + "token": { + "name": "token", + "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 + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_concierge_invites_token_hash": { + "name": "idx_concierge_invites_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "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 + } + }, + "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" + } + }, + "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": {} + }, + "customer_messages": { + "name": "customer_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": true, + "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 + } + }, + "indexes": { + "idx_msg_inspection": { + "name": "idx_msg_inspection", + "columns": [ + "inspection_id", + "created_at" + ], + "isUnique": false + }, + "idx_msg_unread": { + "name": "idx_msg_unread", + "columns": [ + "tenant_id", + "inspection_id", + "from_role" + ], + "isUnique": false, + "where": "\"customer_messages\".\"read_at\" IS NULL" + } + }, + "foreignKeys": { + "customer_messages_tenant_id_tenants_id_fk": { + "name": "customer_messages_tenant_id_tenants_id_fk", + "tableFrom": "customer_messages", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_messages_inspection_id_inspections_id_fk": { + "name": "customer_messages_inspection_id_inspections_id_fk", + "tableFrom": "customer_messages", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active": { + "name": "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 + }, + "discount_codes_code_tenant": { + "name": "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": {} + }, + "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 + }, + "active": { + "name": "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": { + "event_types_tenant_slug_idx": { + "name": "event_types_tenant_slug_idx", + "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": {} + }, + "guest_invites": { + "name": "guest_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 + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claimed_by_user_id": { + "name": "claimed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "guest_invites_token_unique": { + "name": "guest_invites_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "guest_invites_tenant_idx": { + "name": "guest_invites_tenant_idx", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_guest_invites_token_hash": { + "name": "idx_guest_invites_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "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_agreements": { + "name": "inspection_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 + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signature_base64": { + "name": "signature_base64", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signed_at": { + "name": "signed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "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 + } + }, + "indexes": { + "idx_insp_agreements_tenant": { + "name": "idx_insp_agreements_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_insp_agreements_insp": { + "name": "idx_insp_agreements_insp", + "columns": [ + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspection_agreements_tenant_id_tenants_id_fk": { + "name": "inspection_agreements_tenant_id_tenants_id_fk", + "tableFrom": "inspection_agreements", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_agreements_inspection_id_inspections_id_fk": { + "name": "inspection_agreements_inspection_id_inspections_id_fk", + "tableFrom": "inspection_agreements", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_conflicts": { + "name": "inspection_conflicts", + "columns": { + "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 + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "field": { + "name": "field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base": { + "name": "base", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "local": { + "name": "local", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote": { + "name": "remote", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_conflicts_inspection": { + "name": "idx_inspection_conflicts_inspection", + "columns": [ + "inspection_id", + "resolved_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "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": { + "inspection_events_scheduled_idx": { + "name": "inspection_events_scheduled_idx", + "columns": [ + "tenant_id", + "scheduled_at" + ], + "isUnique": false + }, + "inspection_events_inspection_idx": { + "name": "inspection_events_inspection_idx", + "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 + } + }, + "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_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": "text", + "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": { + "name": "total_amount", + "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 + }, + "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": { + "name": "price_override", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name_snapshot": { + "name": "name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_snapshot": { + "name": "price_snapshot", + "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_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": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "inspection_units_tenant_inspection_idx": { + "name": "inspection_units_tenant_inspection_idx", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "inspection_units_parent_idx": { + "name": "inspection_units_parent_idx", + "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 + }, + "client_contact_id": { + "name": "client_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 + }, + "client_phone": { + "name": "client_phone", + "type": "text", + "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": "'draft'" + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unpaid'" + }, + "referred_by_agent_id": { + "name": "referred_by_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price": { + "name": "price", + "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": "text", + "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 + }, + "payment_required": { + "name": "payment_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "agreement_required": { + "name": "agreement_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_sign_on_publish": { + "name": "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": { + "name": "discount_amount", + "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 + }, + "order_id": { + "name": "order_id", + "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 + }, + "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 + }, + "county": { + "name": "county", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selling_agent_id": { + "name": "selling_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disable_automations": { + "name": "disable_automations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "message_token": { + "name": "message_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": 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 + }, + "report_theme_override": { + "name": "report_theme_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 + }, + "team_mode": { + "name": "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 + } + }, + "indexes": { + "idx_inspections_msg_token": { + "name": "idx_inspections_msg_token", + "columns": [ + "message_token" + ], + "isUnique": true + }, + "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_agent": { + "name": "idx_inspections_agent", + "columns": [ + "referred_by_agent_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_tenant_client_email": { + "name": "idx_inspections_tenant_client_email", + "columns": [ + "tenant_id", + "client_email" + ], + "isUnique": false + }, + "idx_inspections_inspector_date": { + "name": "idx_inspections_inspector_date", + "columns": [ + "inspector_id", + "date" + ], + "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_selling_agent_id_contacts_id_fk": { + "name": "inspections_selling_agent_id_contacts_id_fk", + "tableFrom": "inspections", + "tableTo": "contacts", + "columnsFrom": [ + "selling_agent_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": {} + }, + "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 + }, + "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 + } + }, + "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 + }, + "featured": { + "name": "featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_marketplace_libraries_kind_featured": { + "name": "idx_marketplace_libraries_kind_featured", + "columns": [ + "kind", + "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 + }, + "featured": { + "name": "featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "observer_links": { + "name": "observer_links", + "columns": { + "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 + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_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": { + "observer_links_token_unique": { + "name": "observer_links_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "observer_links_inspection_idx": { + "name": "observer_links_inspection_idx", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "idx_observer_links_token_hash": { + "name": "idx_observer_links_token_hash", + "columns": [ + "token_hash" + ], + "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": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "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 + }, + "sync_enabled": { + "name": "sync_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "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 + }, + "resolved": { + "name": "resolved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "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": {} + }, + "recommendations": { + "name": "recommendations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_estimate_min": { + "name": "default_estimate_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_estimate_max": { + "name": "default_estimate_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_repair_summary": { + "name": "default_repair_summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "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 + } + }, + "indexes": { + "idx_recommendations_tenant_category": { + "name": "idx_recommendations_tenant_category", + "columns": [ + "tenant_id", + "category" + ], + "isUnique": false + }, + "idx_recommendations_tenant": { + "name": "idx_recommendations_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "recommendations_tenant_id_tenants_id_fk": { + "name": "recommendations_tenant_id_tenants_id_fk", + "tableFrom": "recommendations", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "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 + }, + "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 + } + }, + "indexes": { + "uq_report_pdfs_inspection_type": { + "name": "uq_report_pdfs_inspection_type", + "columns": [ + "inspection_id", + "type" + ], + "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 + } + }, + "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_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 + }, + "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": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "report_versions_inspection_idx": { + "name": "report_versions_inspection_idx", + "columns": [ + "inspection_id", + "version_number" + ], + "isUnique": false + }, + "report_versions_inspection_version_unique": { + "name": "report_versions_inspection_version_unique", + "columns": [ + "inspection_id", + "version_number" + ], + "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": { + "name": "price", + "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 + }, + "active": { + "name": "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_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": 0 + }, + "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 + }, + "featured": { + "name": "featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "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_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": "text", + "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": "text", + "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": {} + }, + "user_identity_links": { + "name": "user_identity_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "primary_user_id": { + "name": "primary_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_user_id": { + "name": "linked_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_tenant_id": { + "name": "linked_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_role": { + "name": "linked_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_display_name": { + "name": "linked_display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "user_identity_links_primary_idx": { + "name": "user_identity_links_primary_idx", + "columns": [ + "primary_user_id" + ], + "isUnique": false + }, + "user_identity_links_primary_linked_unique": { + "name": "user_identity_links_primary_linked_unique", + "columns": [ + "primary_user_id", + "linked_user_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_invites": { + "name": "agent_invites", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_contact_id": { + "name": "inspector_contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_agent_invites_email": { + "name": "idx_agent_invites_email", + "columns": [ + "email" + ], + "isUnique": false + }, + "idx_agent_invites_tenant": { + "name": "idx_agent_invites_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_agent_invites_expiration": { + "name": "idx_agent_invites_expiration", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "agent_invites_tenant_id_tenants_id_fk": { + "name": "agent_invites_tenant_id_tenants_id_fk", + "tableFrom": "agent_invites", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_invites_invited_by_user_id_users_id_fk": { + "name": "agent_invites_invited_by_user_id_users_id_fk", + "tableFrom": "agent_invites", + "tableTo": "users", + "columnsFrom": [ + "invited_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_tenant_links": { + "name": "agent_tenant_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_user_id": { + "name": "agent_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_contact_id": { + "name": "inspector_contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_agent_tenant_unique": { + "name": "idx_agent_tenant_unique", + "columns": [ + "agent_user_id", + "tenant_id" + ], + "isUnique": true + }, + "idx_agent_tenant_by_tenant": { + "name": "idx_agent_tenant_by_tenant", + "columns": [ + "tenant_id", + "status" + ], + "isUnique": false + }, + "idx_agent_tenant_by_agent": { + "name": "idx_agent_tenant_by_agent", + "columns": [ + "agent_user_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "agent_tenant_links_agent_user_id_users_id_fk": { + "name": "agent_tenant_links_agent_user_id_users_id_fk", + "tableFrom": "agent_tenant_links", + "tableTo": "users", + "columnsFrom": [ + "agent_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_tenant_links_tenant_id_tenants_id_fk": { + "name": "agent_tenant_links_tenant_id_tenants_id_fk", + "tableFrom": "agent_tenant_links", + "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": {} + }, + "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 + }, + "enabled": { + "name": "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": {} + }, + "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 + } + }, + "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 + } + }, + "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 + }, + "blocked_at": { + "name": "blocked_at", + "type": "integer", + "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_configs": { + "name": "tenant_configs", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "site_name": { + "name": "site_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 + }, + "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'" + }, + "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 + }, + "use_inspector_from_name": { + "name": "use_inspector_from_name", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "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 + }, + "integration_config": { + "name": "integration_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "secrets": { + "name": "secrets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_secrets": { + "name": "encrypted_secrets", + "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 + }, + "report_theme": { + "name": "report_theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'modern'" + }, + "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 + }, + "show_estimates": { + "name": "show_estimates", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_repair_list": { + "name": "enable_repair_list", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_customer_repair_export": { + "name": "enable_customer_repair_export", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "block_unpaid": { + "name": "block_unpaid", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "block_unsigned_agreement": { + "name": "block_unsigned_agreement", + "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 + }, + "concierge_review_required": { + "name": "concierge_review_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "allow_inspector_choice": { + "name": "allow_inspector_choice", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_pdf_pipeline": { + "name": "enable_pdf_pipeline", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_sign_on_publish_default": { + "name": "auto_sign_on_publish_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "team_mode_default": { + "name": "team_mode_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "apprentice_review_required": { + "name": "apprentice_review_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "guest_invites_enabled": { + "name": "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 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "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": {} + }, + "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_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": "'[]'" + } + }, + "indexes": { + "idx_invites_tenant": { + "name": "idx_invites_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "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": {} + }, + "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'" + }, + "nachi_number": { + "name": "nachi_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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": {} + }, + "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 + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "service_areas": { + "name": "service_areas", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'admin'" + }, + "google_refresh_token": { + "name": "google_refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "google_calendar_id": { + "name": "google_calendar_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "google_access_token": { + "name": "google_access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "google_token_expiry": { + "name": "google_token_expiry", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 + }, + "totp_enabled": { + "name": "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 + }, + "notify_on_referral": { + "name": "notify_on_referral", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "notify_on_report": { + "name": "notify_on_report", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "notify_on_paid": { + "name": "notify_on_paid", + "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 + }, + "signup_role": { + "name": "signup_role", + "type": "text", + "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 + } + }, + "indexes": { + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + "deleted_at" + ], + "isUnique": false + }, + "users_tenant_email_unique": { + "name": "users_tenant_email_unique", + "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": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "discount_codes_code_tenant": { + "columns": { + "upper(code)": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 5877a1c1..aa1326b8 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -176,6 +176,13 @@ "when": 1780903233574, "tag": "0024_conscious_roughhouse", "breakpoints": true + }, + { + "idx": 25, + "version": "6", + "when": 1780927844876, + "tag": "0025_grey_nitro", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/api/secrets.ts b/server/api/secrets.ts index be9605d9..c13dc144 100644 --- a/server/api/secrets.ts +++ b/server/api/secrets.ts @@ -41,6 +41,10 @@ export const INTEGRATION_SECRET_KEYS = [ 'STRIPE_SECRET_KEY', 'STRIPE_PUBLISHABLE_KEY', 'STRIPE_WEBHOOK_SECRET', + // Track L — Twilio SMS credentials (BYO; platform-default in SaaS via env). + 'TWILIO_ACCOUNT_SID', + 'TWILIO_AUTH_TOKEN', + 'TWILIO_FROM_NUMBER', 'APP_BASE_URL', ] as const; @@ -62,6 +66,9 @@ const KEY_FORMATS: Array<{ key: IntegrationSecretKey; re: RegExp; hint: string } // Cloudflare Turnstile secrets: 0x = real, 1x/2x/3x = documented test secrets. { key: 'TURNSTILE_SECRET_KEY', re: /^[0-3]x/, hint: 'must start with 0x (or a 1x/2x/3x test secret)' }, { key: 'APP_BASE_URL', re: /^https?:\/\//, hint: 'must be an http(s):// URL' }, + { key: 'TWILIO_ACCOUNT_SID', re: /^AC[0-9a-fA-F]{32}$/, hint: 'must be an Account SID (starts with AC, 34 chars)' }, + { key: 'TWILIO_FROM_NUMBER', re: /^\+[1-9]\d{6,14}$/, hint: 'must be an E.164 number (e.g. +15551234567)' }, + // TWILIO_AUTH_TOKEN has no stable public prefix — not format-gated. ]; /** Returns the first format violation among NEW (non-masked) values, or null. */ diff --git a/server/lib/db/schema/compliance.ts b/server/lib/db/schema/compliance.ts index fd09cf3c..02e8ac56 100644 --- a/server/lib/db/schema/compliance.ts +++ b/server/lib/db/schema/compliance.ts @@ -40,3 +40,28 @@ export const erasureLog = sqliteTable('erasure_log', { }, (t) => [ index('idx_erasure_log_tenant').on(t.tenantId, t.createdAt), ]); + +// Track L (D7) — the TCPA disclosure shown at SMS opt-in. version is monotonic; +// the current (max) version is shown to clients and stamped on each consent event. +export const smsDisclosureVersions = sqliteTable('sms_disclosure_versions', { + version: integer('version').primaryKey(), + text: text('text').notNull(), + publishedAt: integer('published_at', { mode: 'timestamp_ms' }).notNull(), +}); + +// Track L (D7) — append-only SMS consent ledger (mirrors erasure_log). Current +// consent state = latest event per (tenant_id, contact_id). Never updated/deleted. +export const smsConsentLog = sqliteTable('sms_consent_log', { + id: text('id').primaryKey(), + tenantId: text('tenant_id').notNull(), + contactId: text('contact_id').notNull(), // the consumer (client) contact + recipientType: text('recipient_type', { enum: ['client'] }).notNull(), + action: text('action', { enum: ['granted', 'revoked'] }).notNull(), + disclosureVersion: integer('disclosure_version').notNull(), + capturedVia: text('captured_via', { enum: ['booking_form', 'optin_link', 'admin'] }).notNull(), + ip: text('ip'), + userAgent: text('user_agent'), + createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(), +}, (t) => [ + index('idx_sms_consent_contact').on(t.tenantId, t.contactId, t.createdAt), +]); diff --git a/server/lib/db/schema/index.ts b/server/lib/db/schema/index.ts index 01e1dc05..a71d2a8c 100644 --- a/server/lib/db/schema/index.ts +++ b/server/lib/db/schema/index.ts @@ -64,4 +64,5 @@ export { inspectionConflicts } from './inspection-conflicts'; export { inspectionAccessTokens } from './portal-access'; // Track I-a GDPR (spec §4) — append-only DSAR erasure decision log. -export { erasureLog } from './compliance'; +// Track L (D7) — SMS consent ledger + disclosure versions. +export { erasureLog, smsDisclosureVersions, smsConsentLog } from './compliance'; diff --git a/server/lib/db/schema/inspection.ts b/server/lib/db/schema/inspection.ts index d19e9e05..faa3484c 100644 --- a/server/lib/db/schema/inspection.ts +++ b/server/lib/db/schema/inspection.ts @@ -542,9 +542,15 @@ export const automations = sqliteTable('automations', { // Track J (D2) — send-time gates, JSON: { requirePaid?: bool, requireSigned?: bool, serviceIds?: string[] }. // null = no gates. Evaluated in flush() at delivery, NOT at trigger time. conditions: text('conditions'), - // Track J (D3) — delivery channel. SMS reserved for Track L; the UI greys it - // and flush() defensively skips channel='sms'. Enum is type-layer only. + // Track J (D2/D3) — DEAD shadow (Track L). Superseded by `channels` below. + // Pre-launch; not dropped because D1 can't rebuild an FK-bearing table. + // -- DEAD (2026-06-08, Track L): replaced by channels[]. Do not read/write. channel: text('channel', { enum: ['email', 'sms'] }).notNull().default('email'), + // Track L (D2) — enabled delivery channels, JSON string[] e.g. '["email","sms"]'. + // A firing emits one automation_logs row per channel. Default email-only. + channels: text('channels').notNull().default('["email"]'), + // Track L (D2) — plain-text SMS template (no HTML, no subject). Null until SMS enabled. + smsBody: text('sms_body'), active: integer('active', { mode: 'boolean' }).notNull().default(true), isDefault: integer('is_default', { mode: 'boolean' }).notNull().default(false), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), @@ -557,7 +563,10 @@ export const automationLogs = sqliteTable('automation_logs', { tenantId: text('tenant_id').notNull().references(() => tenants.id), automationId: text('automation_id').notNull(), inspectionId: text('inspection_id').notNull(), - recipientEmail: text('recipient_email').notNull(), + // Track L — holds the email address for email logs, the E.164 phone for sms logs. + recipient: text('recipient').notNull(), // RENAMED from recipient_email (0025) + // Track L — the log's own delivery channel (a multi-channel rule emits one log each). + channel: text('channel', { enum: ['email', 'sms'] }).notNull().default('email'), sendAt: text('send_at').notNull(), deliveredAt: text('delivered_at'), status: text('status', { enum: ['pending', 'sent', 'failed', 'skipped'] }).notNull().default('pending'), diff --git a/server/lib/db/schema/tenant.ts b/server/lib/db/schema/tenant.ts index 96f51a67..ab3752a5 100644 --- a/server/lib/db/schema/tenant.ts +++ b/server/lib/db/schema/tenant.ts @@ -222,6 +222,10 @@ export const tenantConfigs = sqliteTable('tenant_configs', { // inspector's name overrides the display name and their email becomes the // default Reply-To. emailMode: text('email_mode', { enum: ['platform', 'own'] }).notNull().default('platform'), + // Track L (D3) — SMS sender mode, mirrors email_mode. 'platform' uses the + // platform Twilio env; 'own' uses the tenant's three TWILIO_* secrets (only + // when all three are present, else platform fallback — see resolve-twilio.ts). + smsMode: text('sms_mode', { enum: ['platform', 'own'] }).notNull().default('platform'), senderDisplayName: text('sender_display_name'), useInspectorFromName: integer('use_inspector_from_name', { mode: 'boolean' }).notNull().default(false), billingUrl: text('billing_url'), diff --git a/server/services/automation.service.ts b/server/services/automation.service.ts index 7b95a932..e7f9b71e 100644 --- a/server/services/automation.service.ts +++ b/server/services/automation.service.ts @@ -180,7 +180,7 @@ export class AutomationService { } const sendAt = new Date(now.getTime() + rule.delayMinutes * 60_000).toISOString(); return [{ id: nanoid(), tenantId: ctx.tenantId, automationId: rule.id, - inspectionId: ctx.inspectionId, recipientEmail: email, + inspectionId: ctx.inspectionId, recipient: email, sendAt, deliveredAt: null, status: 'pending' as const, error: null }]; }); @@ -381,7 +381,7 @@ export class AutomationService { const res = await fetch('https://api.resend.com/emails', { method: 'POST', headers: { Authorization: `Bearer ${resendApiKey}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ from, to: [log.recipientEmail], subject, html }), + body: JSON.stringify({ from, to: [log.recipient], subject, html }), }); if (res.ok) { @@ -451,7 +451,7 @@ export class AutomationService { await db.insert(automationLogs).values({ id: nanoid(), tenantId: rule.tenantId, automationId: rule.id, - inspectionId: insp.id, recipientEmail: insp.clientEmail, + inspectionId: insp.id, recipient: insp.clientEmail, sendAt: new Date(sendAt).toISOString(), status: 'pending', eventId, }); created++; diff --git a/server/services/event.service.ts b/server/services/event.service.ts index 0c83aafe..b5df84d9 100644 --- a/server/services/event.service.ts +++ b/server/services/event.service.ts @@ -147,7 +147,7 @@ export class EventService { tenantId, automationId: rule.id as string, inspectionId, - recipientEmail: insp.clientEmail as string, + recipient: insp.clientEmail as string, sendAt: new Date(sendAt).toISOString(), status: 'pending', eventId, @@ -168,7 +168,7 @@ export class EventService { tenantId, automationId: rule.id as string, inspectionId, - recipientEmail: insp.clientEmail as string, + recipient: insp.clientEmail as string, sendAt: new Date(sendAt).toISOString(), status: 'pending', eventId, diff --git a/tests/unit/sms-schema.spec.ts b/tests/unit/sms-schema.spec.ts new file mode 100644 index 00000000..307f34a7 --- /dev/null +++ b/tests/unit/sms-schema.spec.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from 'vitest'; +import * as schema from '../../server/lib/db/schema'; + +describe('Track L schema surface', () => { + it('exposes the consent tables and new columns', () => { + expect(schema.smsConsentLog).toBeDefined(); + expect(schema.smsDisclosureVersions).toBeDefined(); + expect(schema.automations.channels).toBeDefined(); + expect(schema.automations.smsBody).toBeDefined(); + expect(schema.automationLogs.recipient).toBeDefined(); + expect(schema.automationLogs.channel).toBeDefined(); + expect(schema.tenantConfigs.smsMode).toBeDefined(); + }); +}); From 7444090c1c8850d02fa4e5cf8b06300ac3c632de Mon Sep 17 00:00:00 2001 From: important-new Date: Mon, 8 Jun 2026 22:39:59 +0800 Subject: [PATCH 07/19] feat(sms): E.164 phone normalization util --- server/lib/sms/phone.ts | 21 +++++++++++++++++++++ tests/unit/sms-phone.spec.ts | 21 +++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 server/lib/sms/phone.ts create mode 100644 tests/unit/sms-phone.spec.ts diff --git a/server/lib/sms/phone.ts b/server/lib/sms/phone.ts new file mode 100644 index 00000000..c030c516 --- /dev/null +++ b/server/lib/sms/phone.ts @@ -0,0 +1,21 @@ +/** + * Track L — normalize messy field-entered phone numbers to E.164 before an SMS + * send. Conservative US-default: 10 digits → +1XXXXXXXXXX; 11 digits leading 1 → + * +1...; an existing leading '+' is trusted if it yields 8–15 digits. Anything + * else → null (the caller skips the log with reason 'invalid phone'). No external + * dependency — full libphonenumber is overkill for the supported markets (US/CA). + */ +export function normalizeE164(raw: string | null | undefined, defaultCountry: 'US' = 'US'): string | null { + if (!raw) return null; + const trimmed = raw.trim(); + if (trimmed.startsWith('+')) { + const digits = trimmed.slice(1).replace(/\D/g, ''); + return digits.length >= 8 && digits.length <= 15 ? `+${digits}` : null; + } + const digits = trimmed.replace(/\D/g, ''); + if (defaultCountry === 'US') { + if (digits.length === 10) return `+1${digits}`; + if (digits.length === 11 && digits.startsWith('1')) return `+${digits}`; + } + return null; +} diff --git a/tests/unit/sms-phone.spec.ts b/tests/unit/sms-phone.spec.ts new file mode 100644 index 00000000..274af827 --- /dev/null +++ b/tests/unit/sms-phone.spec.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeE164 } from '../../server/lib/sms/phone'; + +describe('normalizeE164 (US default)', () => { + it('passes through already-E.164', () => { + expect(normalizeE164('+15551234567')).toBe('+15551234567'); + }); + it('normalizes US 10-digit with punctuation', () => { + expect(normalizeE164('(555) 123-4567')).toBe('+15551234567'); + expect(normalizeE164('555.123.4567')).toBe('+15551234567'); + }); + it('normalizes US 11-digit leading 1', () => { + expect(normalizeE164('1-555-123-4567')).toBe('+15551234567'); + }); + it('returns null for unparseable / too short', () => { + expect(normalizeE164('12345')).toBeNull(); + expect(normalizeE164('')).toBeNull(); + expect(normalizeE164(null)).toBeNull(); + expect(normalizeE164('not a phone')).toBeNull(); + }); +}); From 10a610e7ef0f19d2c494a1187e6beb9ad4f7139d Mon Sep 17 00:00:00 2001 From: important-new Date: Mon, 8 Jun 2026 22:47:04 +0800 Subject: [PATCH 08/19] feat(sms): Twilio credential resolution via explicit mode toggle (mirrors email) --- server/lib/sms/resolve-twilio.ts | 65 +++++++++++++++++++++++++++ tests/unit/sms-resolve-twilio.spec.ts | 24 ++++++++++ 2 files changed, 89 insertions(+) create mode 100644 server/lib/sms/resolve-twilio.ts create mode 100644 tests/unit/sms-resolve-twilio.spec.ts diff --git a/server/lib/sms/resolve-twilio.ts b/server/lib/sms/resolve-twilio.ts new file mode 100644 index 00000000..5d19623c --- /dev/null +++ b/server/lib/sms/resolve-twilio.ts @@ -0,0 +1,65 @@ +import { loadTenantSecrets } from '../secrets-cache'; +import { tenantConfigs } from '../db/schema'; +import { eq } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/d1'; + +export interface TwilioCreds { sid: string; token: string; from: string; } +type CredBag = Partial>; + +/** + * Pure resolution mirroring assembleTenantEmailService's own-vs-platform logic. + * `own` takes effect ONLY when mode==='own' AND all three tenant keys are present; + * otherwise platform env wins, with tenant creds as a last-resort fallback (so a + * standalone operator who set keys via the Settings UI without flipping mode still + * sends). Returns null when no complete credential set is resolvable (fail-closed). + */ +export function resolveTwilio( + mode: 'platform' | 'own', + tenant: CredBag, + platform: CredBag, +): TwilioCreds | null { + const complete = (b: CredBag): TwilioCreds | null => + b.TWILIO_ACCOUNT_SID && b.TWILIO_AUTH_TOKEN && b.TWILIO_FROM_NUMBER + ? { sid: b.TWILIO_ACCOUNT_SID, token: b.TWILIO_AUTH_TOKEN, from: b.TWILIO_FROM_NUMBER } + : null; + const own = complete(tenant); + if (mode === 'own' && own) return own; + return complete(platform) ?? own; +} + +export interface TwilioLoaderEnv { + DB: D1Database; + TENANT_CACHE: KVNamespace; + JWT_SECRET: string; + JWT_SECRET_PREVIOUS?: string; + TWILIO_ACCOUNT_SID?: string; + TWILIO_AUTH_TOKEN?: string; + TWILIO_FROM_NUMBER?: string; +} + +/** + * Async loader for non-request contexts (cron flush): reads the tenant's sms_mode + * + decrypted TWILIO_* secrets, applies resolveTwilio against the platform env. + */ +export async function loadTwilioForTenant(env: TwilioLoaderEnv, tenantId: string): Promise { + const db = drizzle(env.DB); + const cfg = await db.select({ smsMode: tenantConfigs.smsMode }).from(tenantConfigs) + .where(eq(tenantConfigs.tenantId, tenantId)).get().catch(() => null); + const mode = (cfg?.smsMode as 'platform' | 'own') ?? 'platform'; + const dec = (await loadTenantSecrets( + env.DB, env.TENANT_CACHE, tenantId, env.JWT_SECRET, env.JWT_SECRET_PREVIOUS, + ).catch(() => null)) ?? {}; + return resolveTwilio( + mode, + { + TWILIO_ACCOUNT_SID: dec['TWILIO_ACCOUNT_SID'], + TWILIO_AUTH_TOKEN: dec['TWILIO_AUTH_TOKEN'], + TWILIO_FROM_NUMBER: dec['TWILIO_FROM_NUMBER'], + }, + { + TWILIO_ACCOUNT_SID: env.TWILIO_ACCOUNT_SID, + TWILIO_AUTH_TOKEN: env.TWILIO_AUTH_TOKEN, + TWILIO_FROM_NUMBER: env.TWILIO_FROM_NUMBER, + }, + ); +} diff --git a/tests/unit/sms-resolve-twilio.spec.ts b/tests/unit/sms-resolve-twilio.spec.ts new file mode 100644 index 00000000..865bf267 --- /dev/null +++ b/tests/unit/sms-resolve-twilio.spec.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { resolveTwilio } from '../../server/lib/sms/resolve-twilio'; + +const PLATFORM = { TWILIO_ACCOUNT_SID: 'ACplatform', TWILIO_AUTH_TOKEN: 'tokP', TWILIO_FROM_NUMBER: '+1999' }; +const OWN = { TWILIO_ACCOUNT_SID: 'ACown', TWILIO_AUTH_TOKEN: 'tokO', TWILIO_FROM_NUMBER: '+1888' }; + +describe('resolveTwilio — explicit mode toggle (mirrors email)', () => { + it("mode=own + all three keys → own wins", () => { + expect(resolveTwilio('own', OWN, PLATFORM)).toEqual({ sid: 'ACown', token: 'tokO', from: '+1888' }); + }); + it('mode=own but a key missing → platform fallback', () => { + expect(resolveTwilio('own', { TWILIO_ACCOUNT_SID: 'ACown' }, PLATFORM)) + .toEqual({ sid: 'ACplatform', token: 'tokP', from: '+1999' }); + }); + it('mode=platform → platform env even if tenant keys present', () => { + expect(resolveTwilio('platform', OWN, PLATFORM)).toEqual({ sid: 'ACplatform', token: 'tokP', from: '+1999' }); + }); + it('no platform env and mode=platform → null (fail-closed)', () => { + expect(resolveTwilio('platform', {}, {})).toBeNull(); + }); + it('standalone: mode=platform but only tenant keys set → tenant as last resort', () => { + expect(resolveTwilio('platform', OWN, {})).toEqual({ sid: 'ACown', token: 'tokO', from: '+1888' }); + }); +}); From 7729c02666fc55fe7322b2923461db79e71bbd9b Mon Sep 17 00:00:00 2001 From: important-new Date: Mon, 8 Jun 2026 22:54:36 +0800 Subject: [PATCH 09/19] feat(sms): Twilio REST send + request-signature validation --- server/lib/sms/send-sms.ts | 44 +++++++++++++++++++++++++++++++++++++ tests/unit/sms-send.spec.ts | 37 +++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 server/lib/sms/send-sms.ts create mode 100644 tests/unit/sms-send.spec.ts diff --git a/server/lib/sms/send-sms.ts b/server/lib/sms/send-sms.ts new file mode 100644 index 00000000..2ca4fe65 --- /dev/null +++ b/server/lib/sms/send-sms.ts @@ -0,0 +1,44 @@ +import type { TwilioCreds } from './resolve-twilio'; + +/** Send one SMS via the Twilio REST API. Pure I/O — caller maps ok→sent / !ok→failed. */ +export async function sendTwilioSms( + creds: TwilioCreds, to: string, body: string, +): Promise<{ ok: true } | { ok: false; error: string }> { + const form = new URLSearchParams({ To: to, From: creds.from, Body: body }); + const res = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${creds.sid}/Messages.json`, { + method: 'POST', + headers: { + Authorization: `Basic ${btoa(`${creds.sid}:${creds.token}`)}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: form.toString(), + }); + if (res.ok) return { ok: true }; + const text = await res.text().catch(() => ''); + return { ok: false, error: `twilio ${res.status}: ${text}`.slice(0, 500) }; +} + +/** + * Twilio request signature = base64( HMAC-SHA1( authToken, URL + sorted(k+v) ) ). + * See https://www.twilio.com/docs/usage/security#validating-requests + */ +export async function signParams(authToken: string, url: string, params: Record): Promise { + const data = url + Object.keys(params).sort().map((k) => k + params[k]).join(''); + const key = await crypto.subtle.importKey( + 'raw', new TextEncoder().encode(authToken), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign'], + ); + const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data)); + return btoa(String.fromCharCode(...new Uint8Array(sig))); +} + +export async function validateTwilioSignature( + authToken: string, url: string, params: Record, presented: string, +): Promise { + if (!presented) return false; + const expected = await signParams(authToken, url, params); + // constant-time-ish compare + if (expected.length !== presented.length) return false; + let diff = 0; + for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ presented.charCodeAt(i); + return diff === 0; +} diff --git a/tests/unit/sms-send.spec.ts b/tests/unit/sms-send.spec.ts new file mode 100644 index 00000000..a2819a5f --- /dev/null +++ b/tests/unit/sms-send.spec.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { sendTwilioSms, validateTwilioSignature } from '../../server/lib/sms/send-sms'; + +describe('sendTwilioSms', () => { + beforeEach(() => vi.unstubAllGlobals()); + + it('POSTs form-encoded to the account Messages endpoint with basic auth', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('{"sid":"SM1"}', { status: 201 })); + vi.stubGlobal('fetch', fetchMock); + const res = await sendTwilioSms({ sid: 'ACx', token: 'tok', from: '+1999' }, '+15551234567', 'Hello'); + expect(res.ok).toBe(true); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.twilio.com/2010-04-01/Accounts/ACx/Messages.json'); + expect(init.headers.Authorization).toBe(`Basic ${btoa('ACx:tok')}`); + expect(init.body).toContain('To=%2B15551234567'); + expect(init.body).toContain('From=%2B1999'); + expect(init.body).toContain('Body=Hello'); + }); + + it('returns ok=false + error text on non-2xx', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{"message":"bad"}', { status: 400 }))); + const res = await sendTwilioSms({ sid: 'ACx', token: 'tok', from: '+1999' }, '+1555', 'Hi'); + expect(res.ok).toBe(false); + expect(res.error).toContain('bad'); + }); +}); + +describe('validateTwilioSignature', () => { + it('accepts a correctly-signed request and rejects a tampered one', async () => { + const url = 'https://app.example.com/api/public/sms/inbound'; + const params = { From: '+15551234567', Body: 'STOP' }; + const { signParams } = await import('../../server/lib/sms/send-sms'); + const good = await signParams('authtoken', url, params); + expect(await validateTwilioSignature('authtoken', url, params, good)).toBe(true); + expect(await validateTwilioSignature('authtoken', url, params, 'wrong')).toBe(false); + }); +}); From c32a7f89e7cf1a07ac8ba30e36c12383fd12be02 Mon Sep 17 00:00:00 2001 From: important-new Date: Mon, 8 Jun 2026 23:02:38 +0800 Subject: [PATCH 10/19] feat(sms): consent ledger service (grant/revoke/latest-wins) + disclosure versions --- server/services/sms-consent.service.ts | 53 ++++++++++++++++++++++++++ tests/unit/sms-consent.spec.ts | 39 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 server/services/sms-consent.service.ts create mode 100644 tests/unit/sms-consent.spec.ts diff --git a/server/services/sms-consent.service.ts b/server/services/sms-consent.service.ts new file mode 100644 index 00000000..fa4e8443 --- /dev/null +++ b/server/services/sms-consent.service.ts @@ -0,0 +1,53 @@ +import { drizzle } from 'drizzle-orm/d1'; +import { and, eq, desc, max } from 'drizzle-orm'; +import { smsConsentLog, smsDisclosureVersions } from '../lib/db/schema'; +import { nanoid } from 'nanoid'; + +export type ConsentAction = 'granted' | 'revoked'; +export type CapturedVia = 'booking_form' | 'optin_link' | 'admin'; + +export class SmsConsentService { + constructor(private db: D1Database) {} + private getDrizzle() { return drizzle(this.db); } + + /** Publish a new disclosure version (max+1). Returns the new version number. */ + async publishDisclosure(text: string): Promise { + const db = this.getDrizzle(); + const cur = await db.select({ v: max(smsDisclosureVersions.version) }).from(smsDisclosureVersions).get(); + const version = (cur?.v ?? 0) + 1; + await db.insert(smsDisclosureVersions).values({ version, text, publishedAt: new Date() }); + return version; + } + + async currentDisclosure(): Promise<{ version: number; text: string } | null> { + const db = this.getDrizzle(); + const row = await db.select().from(smsDisclosureVersions) + .orderBy(desc(smsDisclosureVersions.version)).limit(1).get(); + return row ? { version: row.version, text: row.text } : null; + } + + /** Append a consent event for a client contact, stamping the current disclosure version. */ + async record( + tenantId: string, contactId: string, action: ConsentAction, capturedVia: CapturedVia, + meta: { ip?: string; userAgent?: string }, + ) { + const db = this.getDrizzle(); + const disc = await this.currentDisclosure(); + const row = { + id: nanoid(), tenantId, contactId, recipientType: 'client' as const, + action, disclosureVersion: disc?.version ?? 0, capturedVia, + ip: meta.ip ?? null, userAgent: meta.userAgent ?? null, createdAt: new Date(), + }; + await db.insert(smsConsentLog).values(row); + return row; + } + + /** Latest event for (tenant, contact), or null if none. */ + async getLatest(tenantId: string, contactId: string): 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(); + return (row?.action as ConsentAction) ?? null; + } +} diff --git a/tests/unit/sms-consent.spec.ts b/tests/unit/sms-consent.spec.ts new file mode 100644 index 00000000..5b1756f2 --- /dev/null +++ b/tests/unit/sms-consent.spec.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import * as schema from '../../server/lib/db/schema'; +import { createTestDb, setupSchema } from './db'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; +import { SmsConsentService } from '../../server/services/sms-consent.service'; + +const TENANT = '00000000-0000-0000-0000-000000000001'; +const CONTACT = 'contact-1'; +let db: BetterSQLite3Database; +let svc: SmsConsentService; + +beforeEach(async () => { + const fx = createTestDb(); + db = fx.db; + await setupSchema(fx.sqlite); + (mockDrizzle as unknown as ReturnType).mockReturnValue(db); + svc = new SmsConsentService({} as D1Database); + await svc.publishDisclosure('By providing your number you agree to receive texts.'); +}); + +describe('SmsConsentService', () => { + it('no event → getLatest is null', async () => { + expect(await svc.getLatest(TENANT, CONTACT)).toBeNull(); + }); + it('grant then revoke → latest wins (revoked)', async () => { + await svc.record(TENANT, CONTACT, 'granted', 'booking_form', {}); + expect(await svc.getLatest(TENANT, CONTACT)).toBe('granted'); + await svc.record(TENANT, CONTACT, 'revoked', 'admin', {}); + expect(await svc.getLatest(TENANT, CONTACT)).toBe('revoked'); + }); + it('record stamps the current disclosure version', async () => { + const row = await svc.record(TENANT, CONTACT, 'granted', 'optin_link', { ip: '1.2.3.4' }); + expect(row.disclosureVersion).toBe(1); + expect(row.capturedVia).toBe('optin_link'); + }); +}); From a929e1d0e252d045691faa350c121963b4a44bce Mon Sep 17 00:00:00 2001 From: important-new Date: Mon, 8 Jun 2026 23:24:08 +0800 Subject: [PATCH 11/19] =?UTF-8?q?feat(sms):=20engine=20=E2=80=94=20channel?= =?UTF-8?q?s[]/sms=5Fbody=20persistence,=20channel-aware=20address=20resol?= =?UTF-8?q?ution=20+=20fan-out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/lib/validations/automation.schema.ts | 35 ++++- server/services/automation.service.ts | 161 +++++++++++++++----- tests/unit/automation-channels.spec.ts | 134 ++++++++++++++++ tests/unit/automation-conditions.spec.ts | 27 ++-- tests/unit/automation-reminders.spec.ts | 3 +- tests/unit/automation-schema.spec.ts | 13 +- 6 files changed, 305 insertions(+), 68 deletions(-) create mode 100644 tests/unit/automation-channels.spec.ts diff --git a/server/lib/validations/automation.schema.ts b/server/lib/validations/automation.schema.ts index bc984e6c..7735ccf6 100644 --- a/server/lib/validations/automation.schema.ts +++ b/server/lib/validations/automation.schema.ts @@ -31,13 +31,25 @@ export const AutomationSchema = z.object({ subjectTemplate: z.string().describe('TODO describe subjectTemplate field for the OpenInspection MCP integration'), bodyTemplate: z.string().describe('TODO describe bodyTemplate field for the OpenInspection MCP integration'), conditions: z.string().nullable().describe('JSON-encoded send-time gates, or null. Editor parses it.'), - channel: z.enum(AUTOMATION_CHANNELS).describe('Delivery channel; sms reserved for Track L.'), + // Track L (D2) — enabled delivery channels. Replaces the dead `channel` shadow column. + channels: z.array(z.enum(AUTOMATION_CHANNELS)).describe('Enabled delivery channels.'), + smsBody: z.string().nullable().describe('Plain-text SMS template; null when SMS disabled.'), active: z.boolean().describe('TODO describe active field for the OpenInspection MCP integration'), isDefault: z.boolean().describe('TODO describe isDefault field for the OpenInspection MCP integration'), createdAt: z.string().nullable().describe('TODO describe createdAt field for the OpenInspection MCP integration'), }).openapi('Automation'); -export const CreateAutomationSchema = z.object({ +// Track L — an SMS channel needs a non-empty body to send. Shared by Create + Update. +const smsBodyRequiredWhenSms = ( + v: { channels?: readonly string[] | undefined; smsBody?: string | null | undefined }, + ctx: z.RefinementCtx, +) => { + if (v.channels?.includes('sms') && !v.smsBody?.trim()) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['smsBody'], message: 'SMS body is required when SMS is enabled.' }); + } +}; + +const CreateAutomationBase = z.object({ name: z.string().min(1).max(200).describe('TODO describe name field for the OpenInspection MCP integration'), trigger: z.enum(AUTOMATION_TRIGGERS).describe('TODO describe trigger field for the OpenInspection MCP integration'), recipient: z.enum(AUTOMATION_RECIPIENTS).describe('TODO describe recipient field for the OpenInspection MCP integration'), @@ -45,18 +57,27 @@ export const CreateAutomationSchema = z.object({ subjectTemplate: z.string().min(1).describe('TODO describe subjectTemplate field for the OpenInspection MCP integration'), bodyTemplate: z.string().min(1).describe('TODO describe bodyTemplate field for the OpenInspection MCP integration'), conditions: ConditionsSchema.nullish().describe('Send-time gates; null/omitted = none.'), - channel: z.enum(AUTOMATION_CHANNELS).default('email').describe('Delivery channel.'), -}).openapi('CreateAutomation'); + // Track L (D2) — at least one delivery channel; default email-only. + channels: z.array(z.enum(AUTOMATION_CHANNELS)).min(1).default(['email']) + .describe('At least one delivery channel.'), + smsBody: z.string().max(1600).nullish().describe('Plain-text SMS body; required when channels includes sms.'), +}); + +export const CreateAutomationSchema = CreateAutomationBase + .superRefine(smsBodyRequiredWhenSms) + .openapi('CreateAutomation'); -export const UpdateAutomationSchema = CreateAutomationSchema.partial().extend({ +export const UpdateAutomationSchema = CreateAutomationBase.partial().extend({ active: z.boolean().optional().describe('TODO describe active field for the OpenInspection MCP integration'), -}).openapi('UpdateAutomation'); +}).superRefine(smsBodyRequiredWhenSms).openapi('UpdateAutomation'); export const AutomationLogSchema = z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration'), automationId: z.string().describe('TODO describe automationId field for the OpenInspection MCP integration'), inspectionId: z.string().describe('TODO describe inspectionId field for the OpenInspection MCP integration'), - recipientEmail: z.string().describe('TODO describe recipientEmail field for the OpenInspection MCP integration'), + // Track L — email address for email logs, E.164 phone for sms logs. + recipient: z.string().describe('Delivery address: email for email logs, E.164 phone for sms logs.'), + channel: z.enum(AUTOMATION_CHANNELS).describe("This log's own delivery channel."), sendAt: z.string().describe('TODO describe sendAt field for the OpenInspection MCP integration'), deliveredAt: z.string().nullable().describe('TODO describe deliveredAt field for the OpenInspection MCP integration'), status: z.enum(['pending', 'sent', 'failed', 'skipped']).describe('TODO describe status field for the OpenInspection MCP integration'), diff --git a/server/services/automation.service.ts b/server/services/automation.service.ts index e7f9b71e..9b004406 100644 --- a/server/services/automation.service.ts +++ b/server/services/automation.service.ts @@ -69,11 +69,11 @@ export class AutomationService { name: string; trigger: string; recipient: string; delayMinutes: number; subjectTemplate: string; bodyTemplate: string; conditions?: { requirePaid?: boolean; requireSigned?: boolean; serviceIds?: string[] } | null; - channel?: 'email' | 'sms'; + channels?: ('email' | 'sms')[]; smsBody?: string | null; }) { const db = this.getDrizzle(); const id = nanoid(); - const { conditions, channel, ...rest } = data; + const { conditions, channels, smsBody, ...rest } = data; await db.insert(automations).values({ id, tenantId, ...rest, // Casts narrow the public string param to the schema's enum literal @@ -83,7 +83,10 @@ export class AutomationService { // eslint-disable-next-line @typescript-eslint/no-explicit-any recipient: rest.recipient as any, conditions: conditions ? JSON.stringify(conditions) : null, - channel: channel ?? 'email', + // Track L — channels is the live field; the dead `channel` column is left + // to its DB default ('email') so its NOT NULL constraint stays satisfied. + channels: JSON.stringify(channels?.length ? channels : ['email']), + smsBody: smsBody ?? null, active: true, isDefault: false, createdAt: new Date(), }); return (await db.select().from(automations).where(eq(automations.id, id)))[0]; @@ -93,19 +96,22 @@ export class AutomationService { name: string; trigger: string; recipient: string; delayMinutes: number; subjectTemplate: string; bodyTemplate: string; active: boolean; conditions: { requirePaid?: boolean; requireSigned?: boolean; serviceIds?: string[] } | null; - channel: 'email' | 'sms'; + channels: ('email' | 'sms')[]; smsBody: string | null; }>) { const db = this.getDrizzle(); const existing = await db.select().from(automations) .where(and(eq(automations.id, id), eq(automations.tenantId, tenantId))).limit(1); if (!existing[0]) throw Errors.NotFound('Automation not found'); - const { conditions, ...rest } = data; + const { conditions, channels, smsBody, ...rest } = data; const patch: Record = { ...rest }; // Key-presence (not truthiness) so an explicit `conditions: null` clears // the row while an omitted key leaves it untouched. The zod layer strips // absent keys, so `undefined` should not reach here; the guard is belt- // and-braces for direct (non-API) callers. if ('conditions' in data) patch.conditions = conditions ? JSON.stringify(conditions) : null; + // Track L — channels/sms_body persist on the same key-presence contract. + if ('channels' in data) patch.channels = JSON.stringify(channels?.length ? channels : ['email']); + if ('smsBody' in data) patch.smsBody = smsBody ?? null; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- partial patch → table's typed columns; matches the file's create() cast pattern await db.update(automations).set(patch as any) .where(and(eq(automations.id, id), eq(automations.tenantId, tenantId))); @@ -171,18 +177,24 @@ export class AutomationService { if (filteredRules.length === 0) return; const now = new Date(); - const logs = filteredRules.flatMap(rule => { - const email = this.resolveEmail(rule.recipient as string, insp); - if (!email) { - logger.info('AutomationService.trigger: no email resolved (will fan out at delivery)', - { ruleId: rule.id, recipient: rule.recipient }); - return []; + // Track L — fan out one pending log per enabled channel, each stamped with + // the channel-appropriate recipient (email address or normalized E.164 phone). + const logs: (typeof automationLogs.$inferInsert)[] = []; + for (const rule of filteredRules) { + const channels = this.parseChannels(rule.channels); + for (const channel of channels) { + const addr = await this.resolveAddress(rule.recipient as string, channel, insp, db); + if (!addr) { + logger.info('AutomationService.trigger: no address resolved for channel (skipping log)', + { ruleId: rule.id, recipient: rule.recipient, channel }); + continue; + } + const sendAt = new Date(now.getTime() + rule.delayMinutes * 60_000).toISOString(); + logs.push({ id: nanoid(), tenantId: ctx.tenantId, automationId: rule.id, + inspectionId: ctx.inspectionId, recipient: addr, channel, + sendAt, deliveredAt: null, status: 'pending' as const, error: null }); } - const sendAt = new Date(now.getTime() + rule.delayMinutes * 60_000).toISOString(); - return [{ id: nanoid(), tenantId: ctx.tenantId, automationId: rule.id, - inspectionId: ctx.inspectionId, recipient: email, - sendAt, deliveredAt: null, status: 'pending' as const, error: null }]; - }); + } logger.info('AutomationService.trigger: logs prepared', { event: ctx.triggerEvent, count: logs.length }); @@ -210,22 +222,79 @@ export class AutomationService { logger.info('AutomationService: enqueued', { event: ctx.triggerEvent, count: logs.length }); } - private resolveEmail(recipient: string, insp: typeof inspections.$inferSelect): string | null { - if (recipient === 'client') return insp.clientEmail ?? null; - return null; // buying_agent/selling_agent/inspector resolved at delivery + /** + * Track L — resolve the delivery address for a (recipient, channel) pair. + * email → existing behavior (client only; agents/inspector deferred). sms → + * E.164 phone for client / selling_agent / buying_agent / inspector. Returns + * null → the caller skips creating that log (never throws). + */ + private async resolveAddress( + recipient: string, channel: 'email' | 'sms', + insp: typeof inspections.$inferSelect, db: DrizzleD1Database, + ): Promise { + if (channel === 'email') { + return recipient === 'client' ? (insp.clientEmail ?? null) : null; + } + // channel === 'sms' + const { contacts, users } = await import('../lib/db/schema'); + const phoneOf = async (contactId: string | null | undefined) => { + if (!contactId) return null; + const c = await db.select({ phone: contacts.phone }).from(contacts) + .where(eq(contacts.id, contactId)).get().catch(() => null); + return c?.phone ?? null; + }; + let raw: string | null = null; + if (recipient === 'client') { + raw = insp.clientPhone ?? (await phoneOf(insp.clientContactId)); + } else if (recipient === 'selling_agent') { + raw = await phoneOf(insp.sellingAgentId); + } else if (recipient === 'buying_agent') { + // referredByAgentId is an unkeyed TEXT (backward-compat); treat it as a + // contacts.id and resolve a phone if it happens to be one, else null. + raw = await phoneOf(insp.referredByAgentId); + } else if (recipient === 'inspector') { + // Verified against server/lib/db/schema/inspection.ts: the assigned + // inspector is `inspections.inspector_id` (text FK → users.id, line 46). + // `lead_inspector_id` (team mode) is the primary when set and falls back + // to inspector_id per its schema comment, so prefer lead then inspector. + // (The inspection_inspectors join table from DB-8 is a query face only; + // inspectorId/leadInspectorId remain canonical for single-value reads.) + const inspectorId = insp.leadInspectorId ?? insp.inspectorId ?? null; + if (inspectorId) { + const u = await db.select({ phone: users.phone }).from(users) + .where(eq(users.id, inspectorId)).get().catch(() => null); + raw = u?.phone ?? null; + } + } + const { normalizeE164 } = await import('../lib/sms/phone'); + return normalizeE164(raw); + } + + /** + * Track L — parse the JSON `channels` column into a validated channel list. + * Defends against malformed/empty JSON (or a NULL legacy row) by falling back + * to email-only, so a corrupt blob never traps a rule from firing. + */ + private parseChannels(raw: string | null): ('email' | 'sms')[] { + if (!raw) return ['email']; + try { + const arr = JSON.parse(raw); + const valid = Array.isArray(arr) ? arr.filter((c) => c === 'email' || c === 'sms') : []; + return valid.length ? valid : ['email']; + } catch { return ['email']; } } /** * Track J (D4) — evaluate a rule's send-time gates against the CURRENT world. * Returns a skip reason when a gate fails so flush() can mark the log 'skipped'. - * channel='sms' is a defensive skip (Track L will implement the sender). + * Track L — SMS gating (consent/credentials) now lives per-channel in flush(), + * NOT here; this evaluates channel-agnostic conditions only. */ private async evaluateConditions( db: DrizzleD1Database, automation: typeof automations.$inferSelect, inspection: typeof inspections.$inferSelect, ): Promise<{ ok: true } | { ok: false; reason: string }> { - if (automation.channel === 'sms') return { ok: false, reason: 'channel sms not supported yet' }; // Track J (D7) — a reminder enqueued for an inspection that has since // reached a terminal status (cancelled/completed/delivered/published) is // stale; suppress it (e.g. don't send "don't forget tomorrow" for a @@ -437,24 +506,38 @@ export class AutomationService { )); for (const insp of upcoming) { - if (!insp.clientEmail) continue; - const eventId = `reminder:${rule.id}:${insp.id}`; - const dup = await db.select({ id: automationLogs.id }).from(automationLogs) - .where(eq(automationLogs.eventId, eventId)).limit(1); - if (dup.length > 0) continue; - - // tz-naive: 09:00 UTC is an approximate anchor (inspections.date is date-only, no tenant tz here). - const inspMs = Date.parse(`${insp.date}T09:00:00Z`); - if (Number.isNaN(inspMs)) continue; - let sendAt = inspMs - rule.delayMinutes * 60_000; - if (sendAt < nowMs) sendAt = nowMs + 5 * 60_000; - - await db.insert(automationLogs).values({ - id: nanoid(), tenantId: rule.tenantId, automationId: rule.id, - inspectionId: insp.id, recipient: insp.clientEmail, - sendAt: new Date(sendAt).toISOString(), status: 'pending', eventId, - }); - created++; + // Track L — fan out one reminder log per enabled channel. Per-channel + // address resolution replaces the old single clientEmail guard. + const channels = this.parseChannels(rule.channels); + for (const channel of channels) { + const addr = await this.resolveAddress(rule.recipient as string, channel, insp, db); + if (!addr) continue; + // Dedup key is per-channel so email + sms reminders for the same + // (rule, inspection) coexist and each de-dupes independently. + const eventId = `reminder:${rule.id}:${insp.id}:${channel}`; + const dup = await db.select({ id: automationLogs.id }).from(automationLogs) + .where(eq(automationLogs.eventId, eventId)).limit(1); + if (dup.length > 0) continue; + + // tz-naive: 09:00 UTC is an approximate anchor (inspections.date is date-only, no tenant tz here). + const inspMs = Date.parse(`${insp.date}T09:00:00Z`); + if (Number.isNaN(inspMs)) continue; + // send_at here is a DISPLAY ESTIMATE only — flush() derives the real + // reminder due-time live from the current inspection.date (Task 7), + // so a reschedule (a `date` write on the inspection) needs no update + // to this row. We still write the estimate: the column is NOT NULL + // and it is a useful default for display/sort. + let sendAt = inspMs - rule.delayMinutes * 60_000; + if (sendAt < nowMs) sendAt = nowMs + 5 * 60_000; + + await db.insert(automationLogs).values({ + id: nanoid(), tenantId: rule.tenantId, automationId: rule.id, + inspectionId: insp.id, recipient: addr, channel, + // send_at is a display estimate; flush() derives the real due-time live from inspection.date + sendAt: new Date(sendAt).toISOString(), status: 'pending', eventId, + }); + created++; + } } } return created; diff --git a/tests/unit/automation-channels.spec.ts b/tests/unit/automation-channels.spec.ts new file mode 100644 index 00000000..84c22aa8 --- /dev/null +++ b/tests/unit/automation-channels.spec.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import * as schema from '../../server/lib/db/schema'; +import { createTestDb, setupSchema } from './db'; +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'; +import { AutomationService } from '../../server/services/automation.service'; + +const TENANT = '00000000-0000-0000-0000-000000000001'; +let db: BetterSQLite3Database; +let svc: AutomationService; + +beforeEach(async () => { + const fx = createTestDb(); + db = fx.db; + await setupSchema(fx.sqlite); + (mockDrizzle as unknown as ReturnType).mockReturnValue(db); + await db.insert(schema.tenants).values({ + id: TENANT, name: 'Acme', slug: 'acme', status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + }); + svc = new AutomationService({} as D1Database); +}); + +describe('AutomationService — channels + sms_body (Track L)', () => { + it('create persists channels and sms_body', async () => { + const row = await svc.create(TENANT, { + name: 'Multi', trigger: 'report.published', recipient: 'client', + delayMinutes: 0, subjectTemplate: 's', bodyTemplate: 'b', + channels: ['email', 'sms'], smsBody: 'Your report is ready', + }); + expect(JSON.parse(row.channels)).toEqual(['email', 'sms']); + expect(row.smsBody).toBe('Your report is ready'); + }); + + it('create defaults to email-only channels when omitted', async () => { + const row = await svc.create(TENANT, { + name: 'Default', trigger: 'report.published', recipient: 'client', + delayMinutes: 0, subjectTemplate: 's', bodyTemplate: 'b', + }); + expect(JSON.parse(row.channels)).toEqual(['email']); + expect(row.smsBody).toBeNull(); + }); + + it('update can change channels and sms_body', async () => { + const created = await svc.create(TENANT, { + name: 'U', trigger: 'report.published', recipient: 'client', + delayMinutes: 0, subjectTemplate: 's', bodyTemplate: 'b', + }); + const updated = await svc.update(TENANT, created.id, { + channels: ['email', 'sms'], smsBody: 'hi', + }); + expect(JSON.parse(updated.channels)).toEqual(['email', 'sms']); + expect(updated.smsBody).toBe('hi'); + }); + + it('trigger fans out one log per channel for a client with email + phone', async () => { + const inspId = crypto.randomUUID(); + await db.insert(schema.inspections).values({ + id: inspId, tenantId: TENANT, propertyAddress: '1 Main', clientName: 'Jane', + clientEmail: 'jane@example.com', clientPhone: '(555) 123-4567', date: '2026-07-01', + status: 'published', paymentStatus: 'unpaid', price: 0, + agreementRequired: false, paymentRequired: false, createdAt: new Date(), + } as never); + const created = await svc.create(TENANT, { + name: 'R', trigger: 'report.published', recipient: 'client', + delayMinutes: 0, subjectTemplate: 's', bodyTemplate: 'b', + channels: ['email', 'sms'], smsBody: 'sms', + }); + await svc.trigger({ tenantId: TENANT, inspectionId: inspId, triggerEvent: 'report.published', + companyName: 'Acme', reportBaseUrl: 'https://acme.example.com' }); + const logs = (await db.select().from(schema.automationLogs) + .where(eq(schema.automationLogs.inspectionId, inspId)).all()) + .filter((l) => l.automationId === created.id); + const byChannel = Object.fromEntries(logs.map((l) => [l.channel, l.recipient])); + expect(byChannel.email).toBe('jane@example.com'); + expect(byChannel.sms).toBe('+15551234567'); // normalized at resolution + expect(logs.map((l) => l.channel).sort()).toEqual(['email', 'sms']); + }); + + it('trigger skips the sms log when the client has no phone', async () => { + const inspId = crypto.randomUUID(); + await db.insert(schema.inspections).values({ + id: inspId, tenantId: TENANT, propertyAddress: '2 Main', clientName: 'Joe', + clientEmail: 'joe@example.com', date: '2026-07-02', + status: 'published', paymentStatus: 'unpaid', price: 0, + agreementRequired: false, paymentRequired: false, createdAt: new Date(), + } as never); + const created = await svc.create(TENANT, { + name: 'R2', trigger: 'report.published', recipient: 'client', + delayMinutes: 0, subjectTemplate: 's', bodyTemplate: 'b', + channels: ['email', 'sms'], smsBody: 'sms', + }); + await svc.trigger({ tenantId: TENANT, inspectionId: inspId, triggerEvent: 'report.published', + companyName: 'Acme', reportBaseUrl: 'https://acme.example.com' }); + const logs = (await db.select().from(schema.automationLogs) + .where(eq(schema.automationLogs.inspectionId, inspId)).all()) + .filter((l) => l.automationId === created.id); + expect(logs.map((l) => l.channel)).toEqual(['email']); + }); + + it('enqueueReminders fans out a pending log per channel with channel-appropriate recipient', async () => { + const inspId = crypto.randomUUID(); + const future = new Date(Date.now() + 24 * 3600_000).toISOString().slice(0, 10); + await db.insert(schema.inspections).values({ + id: inspId, tenantId: TENANT, propertyAddress: '3 Main', clientName: 'Ann', + clientEmail: 'ann@example.com', clientPhone: '555-123-4567', date: future, + status: 'scheduled', paymentStatus: 'unpaid', price: 0, + agreementRequired: false, paymentRequired: false, createdAt: new Date(), + } as never); + const created = await svc.create(TENANT, { + name: 'Reminder', trigger: 'inspection.reminder', recipient: 'client', + delayMinutes: 1440, subjectTemplate: 's', bodyTemplate: 'b', + channels: ['email', 'sms'], smsBody: 'reminder', + }); + const n = await svc.enqueueReminders(Date.now()); + expect(n).toBe(2); + const logs = (await db.select().from(schema.automationLogs) + .where(eq(schema.automationLogs.inspectionId, inspId)).all()) + .filter((l) => l.automationId === created.id); + const byChannel = Object.fromEntries(logs.map((l) => [l.channel, l.recipient])); + expect(byChannel.email).toBe('ann@example.com'); + expect(byChannel.sms).toBe('+15551234567'); + // dedup key is per-channel + expect(logs.map((l) => l.eventId).sort()).toEqual([ + `reminder:${created.id}:${inspId}:email`, + `reminder:${created.id}:${inspId}:sms`, + ]); + // a re-scan does not double-create + expect(await svc.enqueueReminders(Date.now())).toBe(0); + }); +}); diff --git a/tests/unit/automation-conditions.spec.ts b/tests/unit/automation-conditions.spec.ts index 7247a74a..b3122ede 100644 --- a/tests/unit/automation-conditions.spec.ts +++ b/tests/unit/automation-conditions.spec.ts @@ -24,26 +24,28 @@ beforeEach(async () => { svc = new AutomationService({} as D1Database); }); -describe('AutomationService create/update — conditions + channel (Track J)', () => { - it('serializes conditions to JSON and defaults channel to email', async () => { +describe('AutomationService create/update — conditions + channels (Track J/L)', () => { + it('serializes conditions to JSON and defaults channels to email-only', async () => { const row = await svc.create(TENANT, { name: 'Follow-up', trigger: 'report.published', recipient: 'client', delayMinutes: 1440, subjectTemplate: 's', bodyTemplate: 'b', conditions: { requirePaid: true, serviceIds: ['svc-1'] }, }); - expect(row.channel).toBe('email'); + expect(JSON.parse(row.channels)).toEqual(['email']); expect(JSON.parse(row.conditions!)).toEqual({ requirePaid: true, serviceIds: ['svc-1'] }); }); - it('update can clear conditions and set channel', async () => { + it('update can clear conditions and change channels', async () => { const created = await svc.create(TENANT, { name: 'R', trigger: 'report.published', recipient: 'client', delayMinutes: 0, subjectTemplate: 's', bodyTemplate: 'b', conditions: { requireSigned: true }, }); - const updated = await svc.update(TENANT, created.id, { conditions: null, channel: 'sms' }); + const updated = await svc.update(TENANT, created.id, { + conditions: null, channels: ['email', 'sms'], smsBody: 'hi', + }); expect(updated.conditions).toBeNull(); - expect(updated.channel).toBe('sms'); + expect(JSON.parse(updated.channels)).toEqual(['email', 'sms']); }); }); @@ -73,7 +75,8 @@ async function seedRuleAndLog(opts: { const logId = crypto.randomUUID(); await db.insert(schema.automationLogs).values({ id: logId, tenantId: TENANT, automationId: ruleId, inspectionId: opts.inspectionId, - recipientEmail: 'jane@example.com', sendAt: new Date(Date.now() - 1000).toISOString(), + recipient: 'jane@example.com', channel: opts.channel ?? 'email', + sendAt: new Date(Date.now() - 1000).toISOString(), status: 'pending', } as never); return logId; @@ -125,16 +128,6 @@ describe('AutomationService.flush — send-time gates (Track J)', () => { expect((await statusOf(logId)).status).toBe('skipped'); }); - it('channel sms is skipped defensively', async () => { - const insp = await seedInspection({ paymentStatus: 'paid' }); - const logId = await seedRuleAndLog({ channel: 'sms', inspectionId: insp }); - await svc.flush('rk', 'from@x.com', 'Acme', 'https://acme.example.com'); - const s = await statusOf(logId); - expect(s.status).toBe('skipped'); - expect(s.error).toMatch(/sms/); - expect(fetch).not.toHaveBeenCalled(); - }); - it('review_url body is skipped (fail-closed) until tenant_configs.review_url is set, then sends', async () => { const insp = await seedInspection(); const logId = await seedRuleAndLog({ body: 'Leave us a review: {{review_url}}', inspectionId: insp }); diff --git a/tests/unit/automation-reminders.spec.ts b/tests/unit/automation-reminders.spec.ts index 1ad09777..45d186cb 100644 --- a/tests/unit/automation-reminders.spec.ts +++ b/tests/unit/automation-reminders.spec.ts @@ -57,7 +57,8 @@ describe('AutomationService.enqueueReminders (Track J D7)', () => { const [log] = await logsFor(i); expect(log.status).toBe('pending'); expect(log.automationId).toBe(ruleId); - expect(log.eventId).toBe(`reminder:${ruleId}:${i}`); + // Track L — dedup key is now per-channel (default email-only rule → :email). + expect(log.eventId).toBe(`reminder:${ruleId}:${i}:email`); expect(Date.parse(log.sendAt)).toBe(Date.parse('2026-05-30T09:00:00Z')); }); diff --git a/tests/unit/automation-schema.spec.ts b/tests/unit/automation-schema.spec.ts index 9943a031..60c26cdc 100644 --- a/tests/unit/automation-schema.spec.ts +++ b/tests/unit/automation-schema.spec.ts @@ -12,11 +12,16 @@ describe('CreateAutomationSchema (Track J)', () => { expect(r.success).toBe(true); }); - it('defaults channel to email and accepts sms', () => { + it('defaults channels to email-only and accepts sms with a body (Track L)', () => { const r = CreateAutomationSchema.safeParse(base); - expect(r.success && r.data.channel).toBe('email'); - expect(CreateAutomationSchema.safeParse({ ...base, channel: 'sms' }).success).toBe(true); - expect(CreateAutomationSchema.safeParse({ ...base, channel: 'fax' }).success).toBe(false); + expect(r.success && r.data.channels).toEqual(['email']); + // sms channel requires a non-empty sms body (superRefine) + expect(CreateAutomationSchema.safeParse({ ...base, channels: ['email', 'sms'], smsBody: 'hi' }).success).toBe(true); + expect(CreateAutomationSchema.safeParse({ ...base, channels: ['sms'] }).success).toBe(false); + // unknown channel value is rejected by the enum + expect(CreateAutomationSchema.safeParse({ ...base, channels: ['fax'] }).success).toBe(false); + // empty channels list is rejected (min 1) + expect(CreateAutomationSchema.safeParse({ ...base, channels: [] }).success).toBe(false); }); it('accepts a conditions object and rejects a malformed one', () => { From 7e88fcd9b1d69899f5b58585842ad9d3a6c15247 Mon Sep 17 00:00:00 2001 From: important-new Date: Mon, 8 Jun 2026 23:39:35 +0800 Subject: [PATCH 12/19] fix(sms): channels default must not inject on partial update (Task 6 review) Zod's `.partial()` over a field carrying `.default()` still applies the default and injects the key. UpdateAutomationSchema derived `channels` (and `delayMinutes`) from CreateAutomationBase, so every partial PATCH omitting `channels` parsed to `channels: ['email']`. The service gates on `'channels' in data`, so any such update silently dropped a tenant's enabled SMS channel (data loss). `delayMinutes` had the same hazard via `{...rest}`, resetting a configured delay to 0. Fix: drop `.default()` from the base fields; re-add the defaults only on CreateAutomationSchema. Update keeps both optional with no default, so omitting them leaves the keys absent and the service never rewrites them. Regression covered at the schema-parse level (service tests bypass Zod). Co-Authored-By: Claude Opus 4.8 --- server/lib/validations/automation.schema.ts | 24 ++++++++++++++-- tests/unit/automation-schema.spec.ts | 32 ++++++++++++++++++++- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/server/lib/validations/automation.schema.ts b/server/lib/validations/automation.schema.ts index 7735ccf6..eb69e63f 100644 --- a/server/lib/validations/automation.schema.ts +++ b/server/lib/validations/automation.schema.ts @@ -53,21 +53,39 @@ const CreateAutomationBase = z.object({ name: z.string().min(1).max(200).describe('TODO describe name field for the OpenInspection MCP integration'), trigger: z.enum(AUTOMATION_TRIGGERS).describe('TODO describe trigger field for the OpenInspection MCP integration'), recipient: z.enum(AUTOMATION_RECIPIENTS).describe('TODO describe recipient field for the OpenInspection MCP integration'), - delayMinutes: z.number().int().min(0).default(0).describe('TODO describe delayMinutes field for the OpenInspection MCP integration'), + // No `.default(0)` on the base — same `.partial()` injection hazard as `channels`: it would + // reset a tenant's configured delay to 0 on every partial PATCH that omits it (the service + // spreads `...rest` into the patch). Create re-adds the default below. + delayMinutes: z.number().int().min(0).describe('TODO describe delayMinutes field for the OpenInspection MCP integration'), subjectTemplate: z.string().min(1).describe('TODO describe subjectTemplate field for the OpenInspection MCP integration'), bodyTemplate: z.string().min(1).describe('TODO describe bodyTemplate field for the OpenInspection MCP integration'), conditions: ConditionsSchema.nullish().describe('Send-time gates; null/omitted = none.'), - // Track L (D2) — at least one delivery channel; default email-only. - channels: z.array(z.enum(AUTOMATION_CHANNELS)).min(1).default(['email']) + // Track L (D2) — at least one delivery channel. NOTE: no `.default()` on the base field — + // Zod's `.partial()` keeps the default and would inject `channels: ['email']` on every + // partial PATCH that omits it, silently dropping a tenant's enabled SMS channel (the service + // gates on key-presence via `'channels' in data`). Create adds the default; Update stays + // omit-means-absent. See tests/unit/automation-schema.spec.ts. + channels: z.array(z.enum(AUTOMATION_CHANNELS)).min(1) .describe('At least one delivery channel.'), smsBody: z.string().max(1600).nullish().describe('Plain-text SMS body; required when channels includes sms.'), }); export const CreateAutomationSchema = CreateAutomationBase + .extend({ + // Create-only defaults (kept off the base so `.partial()` doesn't inject them on Update). + delayMinutes: z.number().int().min(0).default(0) + .describe('TODO describe delayMinutes field for the OpenInspection MCP integration'), + channels: z.array(z.enum(AUTOMATION_CHANNELS)).min(1).default(['email']) + .describe('At least one delivery channel.'), + }) .superRefine(smsBodyRequiredWhenSms) .openapi('CreateAutomation'); export const UpdateAutomationSchema = CreateAutomationBase.partial().extend({ + // Update-only: channels stays optional with NO default, so omitting it leaves the key + // absent from parsed output and the service's key-presence gate never rewrites it. + channels: z.array(z.enum(AUTOMATION_CHANNELS)).min(1).optional() + .describe('At least one delivery channel.'), active: z.boolean().optional().describe('TODO describe active field for the OpenInspection MCP integration'), }).superRefine(smsBodyRequiredWhenSms).openapi('UpdateAutomation'); diff --git a/tests/unit/automation-schema.spec.ts b/tests/unit/automation-schema.spec.ts index 60c26cdc..0e294afb 100644 --- a/tests/unit/automation-schema.spec.ts +++ b/tests/unit/automation-schema.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { CreateAutomationSchema } from '../../server/lib/validations/automation.schema'; +import { CreateAutomationSchema, UpdateAutomationSchema } from '../../server/lib/validations/automation.schema'; describe('CreateAutomationSchema (Track J)', () => { const base = { @@ -32,4 +32,34 @@ describe('CreateAutomationSchema (Track J)', () => { const bad = CreateAutomationSchema.safeParse({ ...base, conditions: { serviceIds: 'nope' } }); expect(bad.success).toBe(false); }); + + it('parses without channels and defaults to email-only (Track L)', () => { + const r = CreateAutomationSchema.parse(base); + expect(r.channels).toEqual(['email']); + }); +}); + +describe('UpdateAutomationSchema (Track L — partial-update channel-drop regression)', () => { + it('omitting channels leaves the key ABSENT (no default injection)', () => { + // Regression: Zod `.partial()` over a field carrying `.default()` would still inject + // `channels: ['email']`, and the service gates on `'channels' in data` — silently + // dropping a tenant's enabled SMS channel on any partial PATCH that omits it. + const r = UpdateAutomationSchema.parse({ active: false }); + expect('channels' in r).toBe(false); + // delayMinutes carried the same `.default()`-on-`.partial()` injection hazard. + expect('delayMinutes' in r).toBe(false); + expect(r).toEqual({ active: false }); + }); + + it('explicit channels are kept and round-trip', () => { + const r = UpdateAutomationSchema.parse({ channels: ['email', 'sms'], smsBody: 'x' }); + expect('channels' in r).toBe(true); + expect(r.channels).toEqual(['email', 'sms']); + expect(r.smsBody).toBe('x'); + }); + + it('still enforces sms-requires-body and min-1 on update', () => { + expect(UpdateAutomationSchema.safeParse({ channels: ['sms'] }).success).toBe(false); + expect(UpdateAutomationSchema.safeParse({ channels: [] }).success).toBe(false); + }); }); From ac6a6d46cafedfdd52c9b53847e8871c88e1f44f Mon Sep 17 00:00:00 2001 From: important-new Date: Mon, 8 Jun 2026 23:54:57 +0800 Subject: [PATCH 13/19] feat(sms): flush() Twilio branch (consent gate + render + send) + cron SMS runtime wiring Co-Authored-By: Claude Opus 4.8 --- server/scheduled.ts | 49 ++++++--- server/services/automation.service.ts | 123 ++++++++++++++++++++- tests/unit/automation-flush-sms.spec.ts | 135 ++++++++++++++++++++++++ 3 files changed, 287 insertions(+), 20 deletions(-) create mode 100644 tests/unit/automation-flush-sms.spec.ts diff --git a/server/scheduled.ts b/server/scheduled.ts index 9b5e6ca0..269140c4 100644 --- a/server/scheduled.ts +++ b/server/scheduled.ts @@ -20,6 +20,13 @@ export interface ScheduledEnv { QBO_CLIENT_ID?: string; QBO_CLIENT_SECRET?: string; QBO_WEBHOOK_SECRET?: string; + // Track L — platform-default Twilio creds + the KV used by loadTwilioForTenant + // to read per-tenant secrets. The cron SMS runtime is built only when both + // TENANT_CACHE and JWT_SECRET are present (else SMS logs self-skip 'not configured'). + TWILIO_ACCOUNT_SID?: string; + TWILIO_AUTH_TOKEN?: string; + TWILIO_FROM_NUMBER?: string; + TENANT_CACHE?: KVNamespace; // Core -> portal user-sync transport (A-13/A-14). Producer binding to the // sync queue; the outbox sweeper republishes pending rows through it. // Optional — sweeper is a no-op when missing (standalone). @@ -119,21 +126,33 @@ export async function scheduled( logger.error('[cron] reminder enqueue failed', {}, e instanceof Error ? e : undefined); } - // 3. Automation email queue flush - if (!env.RESEND_API_KEY) { - logger.info('scheduled: RESEND_API_KEY not set, skipping automation flush'); - } else { - try { - const svc = new AutomationService(env.DB); - await svc.flush( - env.RESEND_API_KEY, - env.SENDER_EMAIL || '', - env.APP_NAME || 'OpenInspection', - env.APP_BASE_URL || '', - ); - } catch (e) { - logger.error('[cron] automation flush failed', {}, e instanceof Error ? e : undefined); - } + // 3. Automation queue flush (email + SMS). Always runs: email logs self-skip + // when RESEND_API_KEY is empty; SMS logs resolve their own per-tenant Twilio + // creds (platform env or tenant own) via the runtime built from env below. + try { + const svc = new AutomationService(env.DB); + const sms = (env.TENANT_CACHE && env.JWT_SECRET) + ? { + resolveCreds: (tenantId: string) => + import('./lib/sms/resolve-twilio').then(({ loadTwilioForTenant }) => + loadTwilioForTenant({ + DB: env.DB, TENANT_CACHE: env.TENANT_CACHE!, JWT_SECRET: env.JWT_SECRET!, + ...(env.JWT_SECRET_PREVIOUS ? { JWT_SECRET_PREVIOUS: env.JWT_SECRET_PREVIOUS } : {}), + ...(env.TWILIO_ACCOUNT_SID ? { TWILIO_ACCOUNT_SID: env.TWILIO_ACCOUNT_SID } : {}), + ...(env.TWILIO_AUTH_TOKEN ? { TWILIO_AUTH_TOKEN: env.TWILIO_AUTH_TOKEN } : {}), + ...(env.TWILIO_FROM_NUMBER ? { TWILIO_FROM_NUMBER: env.TWILIO_FROM_NUMBER } : {}), + }, tenantId)), + } + : null; + await svc.flush( + env.RESEND_API_KEY || '', + env.SENDER_EMAIL || '', + env.APP_NAME || 'OpenInspection', + env.APP_BASE_URL || '', + sms, + ); + } catch (e) { + logger.error('[cron] automation flush failed', {}, e instanceof Error ? e : undefined); } // 4. Sweep the user-sync outbox onto the sync queue (no-op for standalone — diff --git a/server/services/automation.service.ts b/server/services/automation.service.ts index 9b004406..2df6daad 100644 --- a/server/services/automation.service.ts +++ b/server/services/automation.service.ts @@ -1,6 +1,6 @@ import { drizzle } from 'drizzle-orm/d1'; import type { DrizzleD1Database } from 'drizzle-orm/d1'; -import { eq, and, lte, gte, sql, desc, notInArray } from 'drizzle-orm'; +import { eq, and, lte, gte, sql, desc, notInArray, ne } from 'drizzle-orm'; import { automations, automationLogs, inspections, tenants, agreementRequests, inspectionServices, tenantConfigs } from '../lib/db/schema'; import { reportUrl } from '../lib/public-urls'; import { AUTOMATION_SEEDS } from '../data/automation-seeds'; @@ -347,20 +347,55 @@ export class AutomationService { return { ok: true }; } - async flush(resendApiKey: string, senderEmail: string, appName: string, appBaseUrl: string, batchSize = 50): Promise { + async flush( + resendApiKey: string, senderEmail: string, appName: string, appBaseUrl: string, + sms?: { resolveCreds: (tenantId: string) => Promise } | null, + batchSize = 50, + ): Promise { const db = this.getDrizzle(); const now = new Date().toISOString(); + const nowMs = Date.parse(now); - const pending = await db.select({ + // Shared 4-table join so both flush queries (non-reminder fast path + + // reminder live-due path) select the same shape. + const baseSelect = () => db.select({ log: automationLogs, automation: automations, inspection: inspections, tenant: tenants, }) .from(automationLogs) .innerJoin(automations, eq(automationLogs.automationId, automations.id)) .innerJoin(inspections, eq(automationLogs.inspectionId, inspections.id)) - .innerJoin(tenants, eq(tenants.id, inspections.tenantId)) - .where(and(eq(automationLogs.status, 'pending'), lte(automationLogs.sendAt, now))) + .innerJoin(tenants, eq(tenants.id, inspections.tenantId)); + + // Non-reminder logs: indexed, batch-limited fast path (unchanged semantics — + // gated on the stored send_at). + const normal = await baseSelect() + .where(and( + eq(automationLogs.status, 'pending'), + lte(automationLogs.sendAt, now), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ne(automations.trigger, 'inspection.reminder' as any), + )) .limit(batchSize); + // Reminder logs: fetch ALL pending (bounded — enqueueReminders only creates + // them inside the lead window), then compute the due moment LIVE from the + // CURRENT inspection.date and keep the due ones. This makes a reschedule + // "just work" with zero log writes: flush ignores the stored send_at for + // reminders. Reminders not yet due stay pending and re-evaluate next tick. + const reminderRows = await baseSelect() + .where(and( + eq(automationLogs.status, 'pending'), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + eq(automations.trigger, 'inspection.reminder' as any), + )); + const dueReminders = reminderRows.filter(({ automation, inspection }) => { + const inspMs = Date.parse(`${inspection.date}T09:00:00Z`); + if (Number.isNaN(inspMs)) return false; + return inspMs - automation.delayMinutes * 60_000 <= nowMs; // derived due-time + }); + + const pending = [...normal, ...dueReminders]; + if (pending.length === 0) return; logger.info('AutomationService.flush: processing', { count: pending.length }); @@ -377,6 +412,19 @@ export class AutomationService { continue; } + // Track L — branch per the log's own channel. SMS resolves its own + // creds + consent in deliverSms; the email path below self-skips when + // no Resend key is configured (rather than 401-looping forever). + if (log.channel === 'sms') { + await this.deliverSms(db, { log, automation, inspection, tenant }, sms, appName, appHost); + continue; + } + if (!resendApiKey) { + await db.update(automationLogs).set({ status: 'skipped', error: 'email not configured' }) + .where(eq(automationLogs.id, log.id)); + continue; + } + const vars: Record = { client_name: inspection.clientName ?? '', property_address: inspection.propertyAddress, @@ -472,6 +520,71 @@ export class AutomationService { } } + /** + * Track L — deliver one SMS automation log via Twilio. Client logs are gated + * on a recorded 'granted' consent event (agents/inspector are implied; D5); + * creds resolve through the injected sms.resolveCreds (per-tenant platform/own). + * Renders the rule's plain-text smsBody with the var map, fail-closed on an + * unconfigured review_url. Maps Twilio ok→sent / !ok→failed; every guard skips + * the log with a reason. Never throws (caller's try/catch marks failed otherwise). + */ + private async deliverSms( + db: DrizzleD1Database, + ctx: { log: typeof automationLogs.$inferSelect; automation: typeof automations.$inferSelect; + inspection: typeof inspections.$inferSelect; tenant: typeof tenants.$inferSelect }, + sms: { resolveCreds: (tenantId: string) => Promise } | null | undefined, + appName: string, appHost: string, + ): Promise { + const { log, automation, inspection, tenant } = ctx; + const skip = (reason: string) => + db.update(automationLogs).set({ status: 'skipped', error: reason }).where(eq(automationLogs.id, log.id)); + + if (!automation.smsBody?.trim()) return void (await skip('no sms body')); + if (!sms) return void (await skip('sms not configured')); + + // Consent gate — client only (agents/inspector implied; D5). + if (automation.recipient === 'client') { + const { SmsConsentService } = await import('./sms-consent.service'); + const consentSvc = new SmsConsentService(this.db); + const contactId = inspection.clientContactId; + const latest = contactId ? await consentSvc.getLatest(inspection.tenantId, contactId) : null; + if (latest !== 'granted') return void (await skip('no sms consent')); + } + + const creds = await sms.resolveCreds(inspection.tenantId); + if (!creds) return void (await skip('sms not configured')); + + const vars: Record = { + client_name: inspection.clientName ?? '', + property_address: inspection.propertyAddress, + scheduled_date: inspection.date, + report_url: reportUrl(appHost, tenant.slug, inspection.id), + company_name: appName, + // company_phone comes from tenants.phone when present; the column may not + // exist on every deploy's tenants row, so read defensively (→ ''). + company_phone: (tenant as { phone?: string | null }).phone ?? '', + }; + // review_url fail-closed (same rule as the email path). + if (automation.smsBody.includes('{{review_url}}')) { + const cfg = await db.select({ reviewUrl: tenantConfigs.reviewUrl }).from(tenantConfigs) + .where(eq(tenantConfigs.tenantId, inspection.tenantId)).get(); + if (!cfg?.reviewUrl) return void (await skip('review_url not configured')); + vars.review_url = cfg.reviewUrl; + } + const body = interpolate(automation.smsBody, vars); + + const { sendTwilioSms } = await import('../lib/sms/send-sms'); + const res = await sendTwilioSms(creds, log.recipient, body); + if (res.ok) { + await db.update(automationLogs).set({ status: 'sent', deliveredAt: new Date().toISOString() }) + .where(eq(automationLogs.id, log.id)); + } else { + await db.update(automationLogs).set({ status: 'failed', error: res.error }) + .where(eq(automationLogs.id, log.id)); + logger.error('AutomationService.flush: twilio send failed', { logId: log.id }); + } + } + /** * Track J (D7) — appointment reminders. Cron-fired daily. For each active * inspection.reminder rule, scan upcoming inspections within the rule's lead diff --git a/tests/unit/automation-flush-sms.spec.ts b/tests/unit/automation-flush-sms.spec.ts new file mode 100644 index 00000000..8289132f --- /dev/null +++ b/tests/unit/automation-flush-sms.spec.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import * as schema from '../../server/lib/db/schema'; +import { createTestDb, setupSchema } from './db'; +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'; +import { AutomationService } from '../../server/services/automation.service'; +import { SmsConsentService } from '../../server/services/sms-consent.service'; + +const TENANT = '00000000-0000-0000-0000-000000000001'; +let db: BetterSQLite3Database; +let svc: AutomationService; + +const CREDS = { sid: 'ACx', token: 'tok', from: '+1999' }; +const smsRuntime = { resolveCreds: vi.fn().mockResolvedValue(CREDS) }; + +beforeEach(async () => { + const fx = createTestDb(); + db = fx.db; await setupSchema(fx.sqlite); + (mockDrizzle as unknown as ReturnType).mockReturnValue(db); + await db.insert(schema.tenants).values({ + id: TENANT, name: 'Acme', slug: 'acme', status: 'active', phone: '+15550001111', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + } as never); + svc = new AutomationService({} as D1Database); + await new SmsConsentService({} as D1Database).publishDisclosure('disclosure'); + smsRuntime.resolveCreds.mockResolvedValue(CREDS); +}); + +async function seedSmsLog(over: { contactId?: string | null } = {}) { + const inspId = crypto.randomUUID(); + await db.insert(schema.inspections).values({ + id: inspId, tenantId: TENANT, propertyAddress: '1 Main', clientName: 'Jane', + clientEmail: 'jane@example.com', clientPhone: '+15551234567', + clientContactId: over.contactId ?? null, date: '2026-07-01', status: 'published', + paymentStatus: 'paid', price: 0, agreementRequired: false, paymentRequired: false, createdAt: new Date(), + } as never); + const ruleId = crypto.randomUUID(); + await db.insert(schema.automations).values({ + id: ruleId, tenantId: TENANT, name: 'R', trigger: 'report.published', recipient: 'client', + delayMinutes: 0, subjectTemplate: 'S', bodyTemplate: 'B', smsBody: 'Hi {{client_name}} — {{company_name}}', + channels: '["sms"]', channel: 'sms', active: true, isDefault: false, createdAt: new Date(), + } as never); + const logId = crypto.randomUUID(); + await db.insert(schema.automationLogs).values({ + id: logId, tenantId: TENANT, automationId: ruleId, inspectionId: inspId, + recipient: '+15551234567', channel: 'sms', + sendAt: new Date(Date.now() - 1000).toISOString(), status: 'pending', + } as never); + return { logId, inspId }; +} + +const statusOf = async (id: string) => + (await db.select().from(schema.automationLogs).where(eq(schema.automationLogs.id, id)).get()); + +describe('flush() — SMS branch (Track L)', () => { + beforeEach(() => vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{"sid":"SM1"}', { status: 201 })))); + + it('client SMS without consent → skipped', async () => { + const { logId } = await seedSmsLog({ contactId: 'c1' }); + await svc.flush('', '', 'Acme', 'https://acme.example.com', smsRuntime); + const r = await statusOf(logId); + expect(r?.status).toBe('skipped'); + expect(r?.error).toMatch(/consent/); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('client SMS with granted consent → sent via Twilio', async () => { + const { logId } = await seedSmsLog({ contactId: 'c1' }); + await new SmsConsentService({} as D1Database).record(TENANT, 'c1', 'granted', 'admin', {}); + await svc.flush('', '', 'Acme', 'https://acme.example.com', smsRuntime); + expect((await statusOf(logId))?.status).toBe('sent'); + const [url] = (fetch as ReturnType).mock.calls[0]; + expect(url).toContain('/Accounts/ACx/Messages.json'); + }); + + it('no resolvable creds → skipped (fail-closed), no fetch', async () => { + const { logId } = await seedSmsLog({ contactId: 'c1' }); + await new SmsConsentService({} as D1Database).record(TENANT, 'c1', 'granted', 'admin', {}); + smsRuntime.resolveCreds.mockResolvedValueOnce(null); + await svc.flush('', '', 'Acme', 'https://acme.example.com', smsRuntime); + expect((await statusOf(logId))?.status).toBe('skipped'); + expect((await statusOf(logId))?.error).toMatch(/not configured/); + expect(fetch).not.toHaveBeenCalled(); + }); +}); + +// Step 3b — reminder due-time is DERIVED live from inspection.date, NOT the +// stored send_at. These prove flush ignores send_at for inspection.reminder logs. +describe('flush() — derived reminder due-time (Track L Step 3b)', () => { + beforeEach(() => vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{"id":"re_1"}', { status: 200 })))); + + async function seedReminder(dateStr: string) { + const inspId = crypto.randomUUID(); + await db.insert(schema.inspections).values({ + id: inspId, tenantId: TENANT, propertyAddress: '1 Main', clientName: 'Jane', + clientEmail: 'jane@example.com', date: dateStr, status: 'confirmed', + paymentStatus: 'unpaid', price: 0, agreementRequired: false, paymentRequired: false, createdAt: new Date(), + } as never); + const ruleId = crypto.randomUUID(); + await db.insert(schema.automations).values({ + id: ruleId, tenantId: TENANT, name: 'Reminder', trigger: 'inspection.reminder', recipient: 'client', + delayMinutes: 1440, subjectTemplate: 'Reminder', bodyTemplate: 'See you tomorrow', + channels: '["email"]', channel: 'email', active: true, isDefault: false, createdAt: new Date(), + } as never); + const logId = crypto.randomUUID(); + await db.insert(schema.automationLogs).values({ + id: logId, tenantId: TENANT, automationId: ruleId, inspectionId: inspId, + recipient: 'jane@example.com', channel: 'email', + // FAR-FUTURE stored send_at — flush must ignore it for reminders. + sendAt: new Date(Date.now() + 365 * 24 * 3600_000).toISOString(), + status: 'pending', eventId: `reminder:${ruleId}:${inspId}:email`, + } as never); + return logId; + } + + it('processes a reminder whose DERIVED due is now (date=tomorrow) despite a far-future send_at', async () => { + const tomorrow = new Date(Date.now() + 24 * 3600_000).toISOString().slice(0, 10); + const logId = await seedReminder(tomorrow); + await svc.flush('rk', 'from@x.com', 'Acme', 'https://acme.example.com'); + // tomorrow@09:00Z − 1440min(=1 day) ≈ today@09:00Z <= now → due → sent. + expect((await statusOf(logId))?.status).toBe('sent'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('leaves a reminder pending when its DERIVED due is in the future (date two weeks out)', async () => { + const twoWeeks = new Date(Date.now() + 14 * 24 * 3600_000).toISOString().slice(0, 10); + const logId = await seedReminder(twoWeeks); + await svc.flush('rk', 'from@x.com', 'Acme', 'https://acme.example.com'); + expect((await statusOf(logId))?.status).toBe('pending'); + expect(fetch).not.toHaveBeenCalled(); + }); +}); From 77fc98356d320593887afc91e948f9c5032a88ff Mon Sep 17 00:00:00 2001 From: important-new Date: Tue, 9 Jun 2026 00:01:16 +0800 Subject: [PATCH 14/19] feat(sms): add tenant_configs.company_phone for {{company_phone}} in SMS (migration 0026) Co-Authored-By: Claude Opus 4.8 --- migrations/0026_premium_white_tiger.sql | 1 + migrations/meta/0026_snapshot.json | 8071 +++++++++++++++++++++++ migrations/meta/_journal.json | 7 + server/lib/db/schema/tenant.ts | 2 + server/services/automation.service.ts | 12 +- 5 files changed, 8088 insertions(+), 5 deletions(-) create mode 100644 migrations/0026_premium_white_tiger.sql create mode 100644 migrations/meta/0026_snapshot.json diff --git a/migrations/0026_premium_white_tiger.sql b/migrations/0026_premium_white_tiger.sql new file mode 100644 index 00000000..a6c18618 --- /dev/null +++ b/migrations/0026_premium_white_tiger.sql @@ -0,0 +1 @@ +ALTER TABLE `tenant_configs` ADD `company_phone` text; \ No newline at end of file diff --git a/migrations/meta/0026_snapshot.json b/migrations/meta/0026_snapshot.json new file mode 100644 index 00000000..21564b11 --- /dev/null +++ b/migrations/meta/0026_snapshot.json @@ -0,0 +1,8071 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a4e94d92-6d7b-4123-90c9-95f98d9effa0", + "prevId": "ac6b4811-3079-4b94-8fb6-af8df96906a0", + "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": false, + "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 + } + }, + "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": {} + }, + "apprentice_reviews": { + "name": "apprentice_reviews", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "apprentice_id": { + "name": "apprentice_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mentor_id": { + "name": "mentor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "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 + }, + "field": { + "name": "field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "proposed_value": { + "name": "proposed_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "decision_value": { + "name": "decision_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision_at": { + "name": "decision_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "submitted_at": { + "name": "submitted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "apprentice_reviews_mentor_status_idx": { + "name": "apprentice_reviews_mentor_status_idx", + "columns": [ + "tenant_id", + "mentor_id", + "status" + ], + "isUnique": false + }, + "apprentice_reviews_inspection_item_idx": { + "name": "apprentice_reviews_inspection_item_idx", + "columns": [ + "inspection_id", + "item_id" + ], + "isUnique": false + }, + "apprentice_reviews_apprentice_idx": { + "name": "apprentice_reviews_apprentice_idx", + "columns": [ + "apprentice_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "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": true, + "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 + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'email'" + }, + "send_at": { + "name": "send_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "text", + "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 + } + }, + "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 + } + }, + "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": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": true, + "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 + }, + "conditions": { + "name": "conditions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'email'" + }, + "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 + }, + "active": { + "name": "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 + } + }, + "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 + } + }, + "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" + } + }, + "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": {} + }, + "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 + }, + "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 + }, + "disabled": { + "name": "disabled", + "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_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_bookings": { + "name": "concierge_bookings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "confirmation_token": { + "name": "confirmation_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invite_token": { + "name": "invite_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slot_start": { + "name": "slot_start", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slot_end": { + "name": "slot_end", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_name": { + "name": "contact_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_email": { + "name": "contact_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_phone": { + "name": "contact_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "concierge_bookings_confirmation_token_unique": { + "name": "concierge_bookings_confirmation_token_unique", + "columns": [ + "confirmation_token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "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": {} + }, + "concierge_invites": { + "name": "concierge_invites", + "columns": { + "token": { + "name": "token", + "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 + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_concierge_invites_token_hash": { + "name": "idx_concierge_invites_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "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 + } + }, + "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" + } + }, + "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": {} + }, + "customer_messages": { + "name": "customer_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": true, + "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 + } + }, + "indexes": { + "idx_msg_inspection": { + "name": "idx_msg_inspection", + "columns": [ + "inspection_id", + "created_at" + ], + "isUnique": false + }, + "idx_msg_unread": { + "name": "idx_msg_unread", + "columns": [ + "tenant_id", + "inspection_id", + "from_role" + ], + "isUnique": false, + "where": "\"customer_messages\".\"read_at\" IS NULL" + } + }, + "foreignKeys": { + "customer_messages_tenant_id_tenants_id_fk": { + "name": "customer_messages_tenant_id_tenants_id_fk", + "tableFrom": "customer_messages", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_messages_inspection_id_inspections_id_fk": { + "name": "customer_messages_inspection_id_inspections_id_fk", + "tableFrom": "customer_messages", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active": { + "name": "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 + }, + "discount_codes_code_tenant": { + "name": "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": {} + }, + "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 + }, + "active": { + "name": "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": { + "event_types_tenant_slug_idx": { + "name": "event_types_tenant_slug_idx", + "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": {} + }, + "guest_invites": { + "name": "guest_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 + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claimed_by_user_id": { + "name": "claimed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "guest_invites_token_unique": { + "name": "guest_invites_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "guest_invites_tenant_idx": { + "name": "guest_invites_tenant_idx", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_guest_invites_token_hash": { + "name": "idx_guest_invites_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "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_agreements": { + "name": "inspection_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 + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signature_base64": { + "name": "signature_base64", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signed_at": { + "name": "signed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "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 + } + }, + "indexes": { + "idx_insp_agreements_tenant": { + "name": "idx_insp_agreements_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_insp_agreements_insp": { + "name": "idx_insp_agreements_insp", + "columns": [ + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspection_agreements_tenant_id_tenants_id_fk": { + "name": "inspection_agreements_tenant_id_tenants_id_fk", + "tableFrom": "inspection_agreements", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_agreements_inspection_id_inspections_id_fk": { + "name": "inspection_agreements_inspection_id_inspections_id_fk", + "tableFrom": "inspection_agreements", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_conflicts": { + "name": "inspection_conflicts", + "columns": { + "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 + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "field": { + "name": "field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base": { + "name": "base", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "local": { + "name": "local", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote": { + "name": "remote", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_conflicts_inspection": { + "name": "idx_inspection_conflicts_inspection", + "columns": [ + "inspection_id", + "resolved_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "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": { + "inspection_events_scheduled_idx": { + "name": "inspection_events_scheduled_idx", + "columns": [ + "tenant_id", + "scheduled_at" + ], + "isUnique": false + }, + "inspection_events_inspection_idx": { + "name": "inspection_events_inspection_idx", + "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 + } + }, + "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_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": "text", + "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": { + "name": "total_amount", + "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 + }, + "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": { + "name": "price_override", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name_snapshot": { + "name": "name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_snapshot": { + "name": "price_snapshot", + "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_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": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "inspection_units_tenant_inspection_idx": { + "name": "inspection_units_tenant_inspection_idx", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "inspection_units_parent_idx": { + "name": "inspection_units_parent_idx", + "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 + }, + "client_contact_id": { + "name": "client_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 + }, + "client_phone": { + "name": "client_phone", + "type": "text", + "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": "'draft'" + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unpaid'" + }, + "referred_by_agent_id": { + "name": "referred_by_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price": { + "name": "price", + "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": "text", + "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 + }, + "payment_required": { + "name": "payment_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "agreement_required": { + "name": "agreement_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_sign_on_publish": { + "name": "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": { + "name": "discount_amount", + "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 + }, + "order_id": { + "name": "order_id", + "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 + }, + "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 + }, + "county": { + "name": "county", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selling_agent_id": { + "name": "selling_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disable_automations": { + "name": "disable_automations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "message_token": { + "name": "message_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": 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 + }, + "report_theme_override": { + "name": "report_theme_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 + }, + "team_mode": { + "name": "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 + } + }, + "indexes": { + "idx_inspections_msg_token": { + "name": "idx_inspections_msg_token", + "columns": [ + "message_token" + ], + "isUnique": true + }, + "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_agent": { + "name": "idx_inspections_agent", + "columns": [ + "referred_by_agent_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_tenant_client_email": { + "name": "idx_inspections_tenant_client_email", + "columns": [ + "tenant_id", + "client_email" + ], + "isUnique": false + }, + "idx_inspections_inspector_date": { + "name": "idx_inspections_inspector_date", + "columns": [ + "inspector_id", + "date" + ], + "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_selling_agent_id_contacts_id_fk": { + "name": "inspections_selling_agent_id_contacts_id_fk", + "tableFrom": "inspections", + "tableTo": "contacts", + "columnsFrom": [ + "selling_agent_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": {} + }, + "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 + }, + "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 + } + }, + "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 + }, + "featured": { + "name": "featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_marketplace_libraries_kind_featured": { + "name": "idx_marketplace_libraries_kind_featured", + "columns": [ + "kind", + "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 + }, + "featured": { + "name": "featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "observer_links": { + "name": "observer_links", + "columns": { + "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 + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_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": { + "observer_links_token_unique": { + "name": "observer_links_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "observer_links_inspection_idx": { + "name": "observer_links_inspection_idx", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "idx_observer_links_token_hash": { + "name": "idx_observer_links_token_hash", + "columns": [ + "token_hash" + ], + "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": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "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 + }, + "sync_enabled": { + "name": "sync_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "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 + }, + "resolved": { + "name": "resolved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "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": {} + }, + "recommendations": { + "name": "recommendations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_estimate_min": { + "name": "default_estimate_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_estimate_max": { + "name": "default_estimate_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_repair_summary": { + "name": "default_repair_summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "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 + } + }, + "indexes": { + "idx_recommendations_tenant_category": { + "name": "idx_recommendations_tenant_category", + "columns": [ + "tenant_id", + "category" + ], + "isUnique": false + }, + "idx_recommendations_tenant": { + "name": "idx_recommendations_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "recommendations_tenant_id_tenants_id_fk": { + "name": "recommendations_tenant_id_tenants_id_fk", + "tableFrom": "recommendations", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "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 + }, + "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 + } + }, + "indexes": { + "uq_report_pdfs_inspection_type": { + "name": "uq_report_pdfs_inspection_type", + "columns": [ + "inspection_id", + "type" + ], + "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 + } + }, + "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_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 + }, + "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": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "report_versions_inspection_idx": { + "name": "report_versions_inspection_idx", + "columns": [ + "inspection_id", + "version_number" + ], + "isUnique": false + }, + "report_versions_inspection_version_unique": { + "name": "report_versions_inspection_version_unique", + "columns": [ + "inspection_id", + "version_number" + ], + "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": { + "name": "price", + "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 + }, + "active": { + "name": "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_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": 0 + }, + "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 + }, + "featured": { + "name": "featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "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_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": "text", + "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": "text", + "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": {} + }, + "user_identity_links": { + "name": "user_identity_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "primary_user_id": { + "name": "primary_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_user_id": { + "name": "linked_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_tenant_id": { + "name": "linked_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_role": { + "name": "linked_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_display_name": { + "name": "linked_display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "user_identity_links_primary_idx": { + "name": "user_identity_links_primary_idx", + "columns": [ + "primary_user_id" + ], + "isUnique": false + }, + "user_identity_links_primary_linked_unique": { + "name": "user_identity_links_primary_linked_unique", + "columns": [ + "primary_user_id", + "linked_user_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_invites": { + "name": "agent_invites", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_contact_id": { + "name": "inspector_contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_agent_invites_email": { + "name": "idx_agent_invites_email", + "columns": [ + "email" + ], + "isUnique": false + }, + "idx_agent_invites_tenant": { + "name": "idx_agent_invites_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_agent_invites_expiration": { + "name": "idx_agent_invites_expiration", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "agent_invites_tenant_id_tenants_id_fk": { + "name": "agent_invites_tenant_id_tenants_id_fk", + "tableFrom": "agent_invites", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_invites_invited_by_user_id_users_id_fk": { + "name": "agent_invites_invited_by_user_id_users_id_fk", + "tableFrom": "agent_invites", + "tableTo": "users", + "columnsFrom": [ + "invited_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_tenant_links": { + "name": "agent_tenant_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_user_id": { + "name": "agent_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_contact_id": { + "name": "inspector_contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_agent_tenant_unique": { + "name": "idx_agent_tenant_unique", + "columns": [ + "agent_user_id", + "tenant_id" + ], + "isUnique": true + }, + "idx_agent_tenant_by_tenant": { + "name": "idx_agent_tenant_by_tenant", + "columns": [ + "tenant_id", + "status" + ], + "isUnique": false + }, + "idx_agent_tenant_by_agent": { + "name": "idx_agent_tenant_by_agent", + "columns": [ + "agent_user_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "agent_tenant_links_agent_user_id_users_id_fk": { + "name": "agent_tenant_links_agent_user_id_users_id_fk", + "tableFrom": "agent_tenant_links", + "tableTo": "users", + "columnsFrom": [ + "agent_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_tenant_links_tenant_id_tenants_id_fk": { + "name": "agent_tenant_links_tenant_id_tenants_id_fk", + "tableFrom": "agent_tenant_links", + "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": {} + }, + "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 + }, + "enabled": { + "name": "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": {} + }, + "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 + } + }, + "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 + } + }, + "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 + }, + "blocked_at": { + "name": "blocked_at", + "type": "integer", + "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_configs": { + "name": "tenant_configs", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "site_name": { + "name": "site_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 + }, + "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'" + }, + "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 + }, + "use_inspector_from_name": { + "name": "use_inspector_from_name", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "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": { + "name": "secrets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_secrets": { + "name": "encrypted_secrets", + "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 + }, + "report_theme": { + "name": "report_theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'modern'" + }, + "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 + }, + "show_estimates": { + "name": "show_estimates", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_repair_list": { + "name": "enable_repair_list", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_customer_repair_export": { + "name": "enable_customer_repair_export", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "block_unpaid": { + "name": "block_unpaid", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "block_unsigned_agreement": { + "name": "block_unsigned_agreement", + "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 + }, + "concierge_review_required": { + "name": "concierge_review_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "allow_inspector_choice": { + "name": "allow_inspector_choice", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_pdf_pipeline": { + "name": "enable_pdf_pipeline", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_sign_on_publish_default": { + "name": "auto_sign_on_publish_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "team_mode_default": { + "name": "team_mode_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "apprentice_review_required": { + "name": "apprentice_review_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "guest_invites_enabled": { + "name": "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 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "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": {} + }, + "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_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": "'[]'" + } + }, + "indexes": { + "idx_invites_tenant": { + "name": "idx_invites_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "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": {} + }, + "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'" + }, + "nachi_number": { + "name": "nachi_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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": {} + }, + "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 + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "service_areas": { + "name": "service_areas", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'admin'" + }, + "google_refresh_token": { + "name": "google_refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "google_calendar_id": { + "name": "google_calendar_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "google_access_token": { + "name": "google_access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "google_token_expiry": { + "name": "google_token_expiry", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 + }, + "totp_enabled": { + "name": "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 + }, + "notify_on_referral": { + "name": "notify_on_referral", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "notify_on_report": { + "name": "notify_on_report", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "notify_on_paid": { + "name": "notify_on_paid", + "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 + }, + "signup_role": { + "name": "signup_role", + "type": "text", + "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 + } + }, + "indexes": { + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + "deleted_at" + ], + "isUnique": false + }, + "users_tenant_email_unique": { + "name": "users_tenant_email_unique", + "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": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "discount_codes_code_tenant": { + "columns": { + "upper(code)": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index aa1326b8..54fbcc64 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -183,6 +183,13 @@ "when": 1780927844876, "tag": "0025_grey_nitro", "breakpoints": true + }, + { + "idx": 26, + "version": "6", + "when": 1780934320075, + "tag": "0026_premium_white_tiger", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/lib/db/schema/tenant.ts b/server/lib/db/schema/tenant.ts index ab3752a5..d17cde55 100644 --- a/server/lib/db/schema/tenant.ts +++ b/server/lib/db/schema/tenant.ts @@ -232,6 +232,8 @@ export const tenantConfigs = sqliteTable('tenant_configs', { // Track J (#122) — per-company Google/Yelp/Facebook review link. The // "Review request" automation stays inert until this is set (fail-closed). reviewUrl: text('review_url'), + // Track L — company contact phone shown in client SMS ({{company_phone}}). + companyPhone: text('company_phone'), integrationConfig: text('integration_config'), // plaintext JSON: {appBaseUrl, turnstileSiteKey, googleClientId} // DEAD (C-15, 2026-06-06): legacy AES-GCM secrets store, soft-retired — // nothing reads or writes it anymore (D1 can't drop columns on FK-referenced diff --git a/server/services/automation.service.ts b/server/services/automation.service.ts index 2df6daad..847a3c0e 100644 --- a/server/services/automation.service.ts +++ b/server/services/automation.service.ts @@ -554,20 +554,22 @@ export class AutomationService { const creds = await sms.resolveCreds(inspection.tenantId); if (!creds) return void (await skip('sms not configured')); + // Load the tenant config row once for the SMS vars: company_phone is used + // unconditionally by the seeded copy ("questions? call {{company_phone}}"), + // and review_url is the fail-closed consumer below. + const cfg = await db.select({ companyPhone: tenantConfigs.companyPhone, reviewUrl: tenantConfigs.reviewUrl }) + .from(tenantConfigs).where(eq(tenantConfigs.tenantId, inspection.tenantId)).get(); + const vars: Record = { client_name: inspection.clientName ?? '', property_address: inspection.propertyAddress, scheduled_date: inspection.date, report_url: reportUrl(appHost, tenant.slug, inspection.id), company_name: appName, - // company_phone comes from tenants.phone when present; the column may not - // exist on every deploy's tenants row, so read defensively (→ ''). - company_phone: (tenant as { phone?: string | null }).phone ?? '', + company_phone: cfg?.companyPhone ?? '', }; // review_url fail-closed (same rule as the email path). if (automation.smsBody.includes('{{review_url}}')) { - const cfg = await db.select({ reviewUrl: tenantConfigs.reviewUrl }).from(tenantConfigs) - .where(eq(tenantConfigs.tenantId, inspection.tenantId)).get(); if (!cfg?.reviewUrl) return void (await skip('review_url not configured')); vars.review_url = cfg.reviewUrl; } From 78bf665d0c53cd948852712f2b0344be34994d94 Mon Sep 17 00:00:00 2001 From: important-new Date: Tue, 9 Jun 2026 00:12:20 +0800 Subject: [PATCH 15/19] test(sms): mirror sms_mode + company_phone into workers inline tenant_configs DDL The workers tests hand-maintain an inline CREATE TABLE tenant_configs; Task 1 (sms_mode) and Task 7 (company_phone, migration 0026) added columns the Drizzle apply path now SELECTs/writes, breaking test:workers (13/33). Mirror both columns at their schema positions. test:workers 33/33 green. Co-Authored-By: Claude Opus 4.8 --- tests/workers/cmd-consumer.spec.ts | 2 +- tests/workers/cmd-fixtures.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/workers/cmd-consumer.spec.ts b/tests/workers/cmd-consumer.spec.ts index f7377b36..7f24c515 100644 --- a/tests/workers/cmd-consumer.spec.ts +++ b/tests/workers/cmd-consumer.spec.ts @@ -60,7 +60,7 @@ async function seedSchema(): Promise { // SELECTed by drizzle present but unconstrained — the apply path only ever // writes (tenant_id, site_name, updated_at) here. await b.DB.exec( - 'CREATE TABLE IF NOT EXISTS tenant_configs (tenant_id TEXT PRIMARY KEY, site_name TEXT, primary_color TEXT, logo_url TEXT, support_email TEXT, sender_email TEXT, reply_to TEXT, email_mode TEXT, sender_display_name TEXT, use_inspector_from_name INTEGER, billing_url TEXT, review_url TEXT, integration_config TEXT, secrets TEXT, encrypted_secrets TEXT, dek_enc TEXT, ics_token TEXT, widget_allowed_origins TEXT, report_theme TEXT, attention_thresholds TEXT, inspection_prefs TEXT, show_estimates INTEGER, enable_repair_list INTEGER, enable_customer_repair_export INTEGER, block_unpaid INTEGER, block_unsigned_agreement INTEGER, custom_referral_sources TEXT, dashboard_column_prefs TEXT, concierge_review_required INTEGER, allow_inspector_choice INTEGER, enable_pdf_pipeline INTEGER, auto_sign_on_publish_default INTEGER, team_mode_default TEXT, apprentice_review_required INTEGER, guest_invites_enabled INTEGER, require_defect_fields TEXT, agreement_retention_years INTEGER, updated_at INTEGER);', + 'CREATE TABLE IF NOT EXISTS tenant_configs (tenant_id TEXT PRIMARY KEY, site_name TEXT, primary_color TEXT, logo_url TEXT, support_email TEXT, sender_email TEXT, reply_to TEXT, email_mode TEXT, sms_mode TEXT, sender_display_name TEXT, use_inspector_from_name INTEGER, billing_url TEXT, review_url TEXT, company_phone TEXT, integration_config TEXT, secrets TEXT, encrypted_secrets TEXT, dek_enc TEXT, ics_token TEXT, widget_allowed_origins TEXT, report_theme TEXT, attention_thresholds TEXT, inspection_prefs TEXT, show_estimates INTEGER, enable_repair_list INTEGER, enable_customer_repair_export INTEGER, block_unpaid INTEGER, block_unsigned_agreement INTEGER, custom_referral_sources TEXT, dashboard_column_prefs TEXT, concierge_review_required INTEGER, allow_inspector_choice INTEGER, enable_pdf_pipeline INTEGER, auto_sign_on_publish_default INTEGER, team_mode_default TEXT, apprentice_review_required INTEGER, guest_invites_enabled INTEGER, require_defect_fields TEXT, agreement_retention_years INTEGER, updated_at INTEGER);', ); } diff --git a/tests/workers/cmd-fixtures.spec.ts b/tests/workers/cmd-fixtures.spec.ts index dd56ac38..ff2f3dfe 100644 --- a/tests/workers/cmd-fixtures.spec.ts +++ b/tests/workers/cmd-fixtures.spec.ts @@ -43,7 +43,7 @@ describe('cmd golden fixtures — consumer can apply every fixture (A-21)', () = // tenant_configs.siteName (IA-27). Columns unconstrained on purpose — // only (tenant_id, site_name, updated_at) are written by this path. await b.DB.exec( - 'CREATE TABLE IF NOT EXISTS tenant_configs (tenant_id TEXT PRIMARY KEY, site_name TEXT, primary_color TEXT, logo_url TEXT, support_email TEXT, sender_email TEXT, reply_to TEXT, email_mode TEXT, sender_display_name TEXT, use_inspector_from_name INTEGER, billing_url TEXT, review_url TEXT, integration_config TEXT, secrets TEXT, encrypted_secrets TEXT, dek_enc TEXT, ics_token TEXT, widget_allowed_origins TEXT, report_theme TEXT, attention_thresholds TEXT, inspection_prefs TEXT, show_estimates INTEGER, enable_repair_list INTEGER, enable_customer_repair_export INTEGER, block_unpaid INTEGER, block_unsigned_agreement INTEGER, custom_referral_sources TEXT, dashboard_column_prefs TEXT, concierge_review_required INTEGER, allow_inspector_choice INTEGER, enable_pdf_pipeline INTEGER, auto_sign_on_publish_default INTEGER, team_mode_default TEXT, apprentice_review_required INTEGER, guest_invites_enabled INTEGER, require_defect_fields TEXT, agreement_retention_years INTEGER, updated_at INTEGER);', + 'CREATE TABLE IF NOT EXISTS tenant_configs (tenant_id TEXT PRIMARY KEY, site_name TEXT, primary_color TEXT, logo_url TEXT, support_email TEXT, sender_email TEXT, reply_to TEXT, email_mode TEXT, sms_mode TEXT, sender_display_name TEXT, use_inspector_from_name INTEGER, billing_url TEXT, review_url TEXT, company_phone TEXT, integration_config TEXT, secrets TEXT, encrypted_secrets TEXT, dek_enc TEXT, ics_token TEXT, widget_allowed_origins TEXT, report_theme TEXT, attention_thresholds TEXT, inspection_prefs TEXT, show_estimates INTEGER, enable_repair_list INTEGER, enable_customer_repair_export INTEGER, block_unpaid INTEGER, block_unsigned_agreement INTEGER, custom_referral_sources TEXT, dashboard_column_prefs TEXT, concierge_review_required INTEGER, allow_inspector_choice INTEGER, enable_pdf_pipeline INTEGER, auto_sign_on_publish_default INTEGER, team_mode_default TEXT, apprentice_review_required INTEGER, guest_invites_enabled INTEGER, require_defect_fields TEXT, agreement_retention_years INTEGER, updated_at INTEGER);', ); }); From 3a67b0af15fccbfed8a988b94ab3c731e1973160 Mon Sep 17 00:00:00 2001 From: important-new Date: Tue, 9 Jun 2026 00:42:56 +0800 Subject: [PATCH 16/19] =?UTF-8?q?feat(sms):=20ensureClientContact=20(D6b)?= =?UTF-8?q?=20=E2=80=94=20find-or-create=20+=20back-link=20client=20contac?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- server/lib/sms/ensure-client-contact.ts | 40 +++++++++++ tests/unit/sms-ensure-contact.spec.ts | 92 +++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 server/lib/sms/ensure-client-contact.ts create mode 100644 tests/unit/sms-ensure-contact.spec.ts diff --git a/server/lib/sms/ensure-client-contact.ts b/server/lib/sms/ensure-client-contact.ts new file mode 100644 index 00000000..a90bd933 --- /dev/null +++ b/server/lib/sms/ensure-client-contact.ts @@ -0,0 +1,40 @@ +import { drizzle } from 'drizzle-orm/d1'; +import { and, eq, isNull } from 'drizzle-orm'; +import { contacts, inspections } from '../db/schema'; +import { nanoid } from 'nanoid'; + +/** + * Track L (D6b) — guarantee a client contact to attach SMS consent to. Returns + * the linked contact id if present; else find-or-creates one (dedupe by + * (tenant,email) when an email exists) and back-links inspections.client_contact_id. + * Returns null only when the inspection has neither a contact nor any client + * name/email/phone to create from (degenerate; caller skips consent). + */ +export async function ensureClientContact( + dbRaw: D1Database, tenantId: string, inspection: typeof inspections.$inferSelect, +): Promise { + const db = drizzle(dbRaw); + if (inspection.clientContactId) return inspection.clientContactId; + + const email = inspection.clientEmail?.trim() || null; + const name = inspection.clientName?.trim() || email || inspection.clientPhone?.trim() || null; + if (!name && !email && !inspection.clientPhone) return null; + + let contactId: string | null = null; + if (email) { + const existing = await db.select({ id: contacts.id }).from(contacts) + .where(and(eq(contacts.tenantId, tenantId), eq(contacts.email, email), isNull(contacts.archivedAt))) + .get(); + if (existing) contactId = existing.id; + } + if (!contactId) { + contactId = nanoid(); + await db.insert(contacts).values({ + id: contactId, tenantId, type: 'client', name: name ?? 'Client', + email, phone: inspection.clientPhone ?? null, createdAt: new Date(), + } as never); + } + await db.update(inspections).set({ clientContactId: contactId }) + .where(and(eq(inspections.id, inspection.id), eq(inspections.tenantId, tenantId))); + return contactId; +} diff --git a/tests/unit/sms-ensure-contact.spec.ts b/tests/unit/sms-ensure-contact.spec.ts new file mode 100644 index 00000000..3a5e3b08 --- /dev/null +++ b/tests/unit/sms-ensure-contact.spec.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as schema from '../../server/lib/db/schema'; +import { createTestDb, setupSchema } from './db'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { and, eq } from 'drizzle-orm'; + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; +import { ensureClientContact } from '../../server/lib/sms/ensure-client-contact'; + +const TENANT = '00000000-0000-0000-0000-000000000001'; + +let db: BetterSQLite3Database; +let sqlite: { close: () => void }; + +beforeEach(async () => { + const fx = createTestDb(); + db = fx.db as BetterSQLite3Database; + sqlite = fx.sqlite; + await setupSchema(fx.sqlite); + (mockDrizzle as unknown as ReturnType).mockReturnValue(db); + await db.insert(schema.tenants).values({ + id: TENANT, name: 'Acme', slug: 'acme', status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + } as never); +}); + +afterEach(() => sqlite.close()); + +async function seedInspection(over: Partial = {}) { + const id = crypto.randomUUID(); + await db.insert(schema.inspections).values({ + id, tenantId: TENANT, propertyAddress: '1 Main', clientName: 'Jane', + clientEmail: 'jane@example.com', clientPhone: '(555) 123-4567', + date: '2026-07-01', status: 'draft', paymentStatus: 'unpaid', price: 0, + agreementRequired: false, paymentRequired: false, createdAt: new Date(), + ...over, + } as never); + return (await db.select().from(schema.inspections).where(eq(schema.inspections.id, id)).get())!; +} + +describe('ensureClientContact (D6b)', () => { + it('already-linked → returns the existing contact id, creates nothing', async () => { + const existingId = crypto.randomUUID(); + await db.insert(schema.contacts).values({ + id: existingId, tenantId: TENANT, type: 'client', name: 'Jane', + email: 'jane@example.com', createdAt: new Date(), + } as never); + const insp = await seedInspection({ clientContactId: existingId }); + + const result = await ensureClientContact({} as D1Database, TENANT, insp); + expect(result).toBe(existingId); + const all = await db.select().from(schema.contacts).all(); + expect(all.length).toBe(1); + }); + + it('free-typed client with email → creates a contact + back-links inspection', async () => { + const insp = await seedInspection({ clientContactId: null }); + const result = await ensureClientContact({} as D1Database, TENANT, insp); + expect(result).toBeTruthy(); + + const contact = await db.select().from(schema.contacts) + .where(eq(schema.contacts.id, result!)).get(); + expect(contact?.email).toBe('jane@example.com'); + expect(contact?.name).toBe('Jane'); + + const refreshed = await db.select().from(schema.inspections) + .where(eq(schema.inspections.id, insp.id)).get(); + expect(refreshed?.clientContactId).toBe(result); + }); + + it('same email on a second inspection → dedupes to the same contact', async () => { + const insp1 = await seedInspection({ clientContactId: null }); + const c1 = await ensureClientContact({} as D1Database, TENANT, insp1); + + const insp2 = await seedInspection({ clientContactId: null, clientName: 'Jane (rebook)' }); + const c2 = await ensureClientContact({} as D1Database, TENANT, insp2); + + expect(c2).toBe(c1); + const clients = await db.select().from(schema.contacts) + .where(and(eq(schema.contacts.tenantId, TENANT), eq(schema.contacts.email, 'jane@example.com'))).all(); + expect(clients.length).toBe(1); + }); + + it('no contact and no client data at all → null', async () => { + const insp = await seedInspection({ + clientContactId: null, clientName: null, clientEmail: null, clientPhone: null, + }); + const result = await ensureClientContact({} as D1Database, TENANT, insp); + expect(result).toBeNull(); + }); +}); From cc8da2ca33f84b7cdc2868b8581d1de5a69d0157 Mon Sep 17 00:00:00 2001 From: important-new Date: Tue, 9 Jun 2026 00:56:03 +0800 Subject: [PATCH 17/19] =?UTF-8?q?feat(sms):=20consent=20capture=20?= =?UTF-8?q?=E2=80=94=20opt-in=20link=20page,=20inspector=20attestation,=20?= =?UTF-8?q?inbound=20webhook,=20booking=20checkbox=20+=20email=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 8 (Track L): - server/api/sms.ts: public opt-in resolve/confirm + two-shape Twilio inbound webhook (platform shared-number /sms/inbound + tenant-scoped /sms/inbound/:slug), signature-validated (APP_BASE_URL+path); admin attest/test-send/consent-status (requireRole owner/admin). Routers mounted in server/index.ts. - Opt-in token (Step 0 decision): NO new table — self-describing sealed payload ~sealToken(contactId) via lib/sms/optin-token.ts (reuses the config-crypto tier-2 envelope, same as agreement tokens). - app/routes/public/sms-optin.tsx: BFF SSR double-opt-in page (DS tokens, dark-safe). - Path A: booking-form unchecked SMS opt-in checkbox -> granted (booking_form). - Path B: opt-in link injected into the booking-confirmation email at the renderer level (survives template overrides / rule disabling). - audit: sms.consent.attest + sms.test_send; route tag 'sms' allowlisted; TWILIO_* added to request Bindings; typed BFF client surface registered. - settings-automations.tsx: minimal channels[]/recipient interface alignment so the tree type-checks after the Task 6 rename (full multi-channel editor is Task 9). NOTE: inbound non-command bodies are acknowledged but NOT persisted (automation_logs automation_id/inspection_id are NOT NULL; an inbound reply binds neither; two-way surfacing is out of scope per spec §10). STOP/START consent sync is fully handled. Co-Authored-By: Claude Opus 4.8 --- app/lib/api-client.server.ts | 8 + app/routes.ts | 2 + app/routes/public/booking.tsx | 17 ++ app/routes/public/sms-optin.tsx | 120 ++++++++ app/routes/settings-automations.tsx | 14 +- packages/api-types/index.ts | 1 + server/api/bookings.ts | 39 ++- server/api/sms.ts | 296 ++++++++++++++++++++ server/index.ts | 5 + server/lib/audit.ts | 4 +- server/lib/route-metadata-standards.ts | 1 + server/lib/sms/optin-token.ts | 42 +++ server/lib/validations/booking.schema.ts | 3 + server/lib/validations/sms.schema.ts | 23 ++ server/services/email.service.ts | 11 +- server/services/sms-consent.service.ts | 2 +- server/types/hono.ts | 6 + tests/unit/sms-api.spec.ts | 250 +++++++++++++++++ tests/web/unit/settings-automations.spec.ts | 4 +- 19 files changed, 838 insertions(+), 10 deletions(-) create mode 100644 app/routes/public/sms-optin.tsx create mode 100644 server/api/sms.ts create mode 100644 server/lib/sms/optin-token.ts create mode 100644 server/lib/validations/sms.schema.ts create mode 100644 tests/unit/sms-api.spec.ts diff --git a/app/lib/api-client.server.ts b/app/lib/api-client.server.ts index 8e055650..36230350 100644 --- a/app/lib/api-client.server.ts +++ b/app/lib/api-client.server.ts @@ -46,6 +46,8 @@ import type { SecretsApi, ServicesApi, SessionContextApi, + SmsPublicApi, + SmsAdminApi, TagsApi, TeamApi, TemplateMigrationsApi, @@ -146,6 +148,8 @@ export interface Api { secrets: ReturnType>; services: ReturnType>; sessionContext: ReturnType>; + smsPublic: ReturnType>; + smsAdmin: ReturnType>; tags: ReturnType>; team: ReturnType>; templateMigrations: ReturnType>; @@ -206,6 +210,8 @@ const MOUNT: Record = { secrets: "/api/admin", services: "/api/services", sessionContext: "/api/session", + smsPublic: "/api/public", + smsAdmin: "/api/admin", tags: "/api/tags", team: "/api/team", templateMigrations: "/api/templates", @@ -284,6 +290,8 @@ export function createApi(context: AppLoadContext, opts: CreateApiOptions = {}): secrets: mk(MOUNT.secrets), services: mk(MOUNT.services), sessionContext: mk(MOUNT.sessionContext), + smsPublic: mk(MOUNT.smsPublic), + smsAdmin: mk(MOUNT.smsAdmin), tags: mk(MOUNT.tags), team: mk(MOUNT.team), templateMigrations: mk(MOUNT.templateMigrations), diff --git a/app/routes.ts b/app/routes.ts index 5a6d11ab..0ca866be 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -51,6 +51,8 @@ export default [ "routes/public/report-card-stack.tsx", ), route("messages/:token", "routes/public/messages.tsx"), + // Track L (D6, path B) — public SMS double-opt-in confirmation page. + route("sms-optin/:token", "routes/public/sms-optin.tsx"), route("r/:id/repair-request", "routes/public/repair-request.tsx"), route( "agreements/print/:token", diff --git a/app/routes/public/booking.tsx b/app/routes/public/booking.tsx index 675341c8..9803e871 100644 --- a/app/routes/public/booking.tsx +++ b/app/routes/public/booking.tsx @@ -104,6 +104,8 @@ export default function BookingPage() { const [customTime, setCustomTime] = useState("09:00"); const [clientName, setClientName] = useState(""); const [clientEmail, setClientEmail] = useState(""); + // Track L (D6, path A) — unchecked-by-default SMS opt-in (TCPA consent). + const [smsOptin, setSmsOptin] = useState(false); const [chosenInspectorId, setChosenInspectorId] = useState(preselected?.id ?? null); const [submitting, setSubmitting] = useState(false); const [message, setMessage] = useState<{ text: string; ok: boolean } | null>(null); @@ -190,6 +192,7 @@ export default function BookingPage() { services: [...selectedServices].map(id => ({ serviceId: id })), clientName, clientEmail, + ...(smsOptin ? { smsOptin: true } : {}), ...(turnstileToken ? { turnstileToken } : {}), ...(agentRefSlug ? { agentRefSlug } : {}), }), @@ -450,6 +453,20 @@ export default function BookingPage() { />
+ {/* Track L (D6, path A) — unchecked SMS opt-in (TCPA consent). */} +
)} diff --git a/app/routes/public/sms-optin.tsx b/app/routes/public/sms-optin.tsx new file mode 100644 index 00000000..c2b26c72 --- /dev/null +++ b/app/routes/public/sms-optin.tsx @@ -0,0 +1,120 @@ +import { useLoaderData, useActionData, useNavigation, Form } from "react-router"; +import type { Route } from "./+types/sms-optin"; +import { createApi } from "~/lib/api-client.server"; + +export function meta() { + return [{ title: "Text message updates - OpenInspection" }]; +} + +interface OptinData { + companyName: string; + disclosureText: string; +} + +/* ------------------------------------------------------------------ */ +/* Loader — resolve the token to disclosure + company name (BFF) */ +/* ------------------------------------------------------------------ */ + +export async function loader({ params, context }: Route.LoaderArgs) { + const token = params.token ?? ""; + try { + const api = createApi(context); + const res = (await api.smsPublic.sms["optin-resolve"].$get({ + query: { token }, + })) as unknown as Response; + if (!res.ok) return { data: null as OptinData | null, token }; + const body = (await res.json()) as { data?: OptinData }; + return { data: body.data ?? null, token }; + } catch { + return { data: null as OptinData | null, token }; + } +} + +/* ------------------------------------------------------------------ */ +/* Action — confirm opt-in (BFF, no client fetch) */ +/* ------------------------------------------------------------------ */ + +export async function action({ params, context }: Route.ActionArgs) { + const token = params.token ?? ""; + try { + const api = createApi(context); + const res = (await api.smsPublic.sms["optin-confirm"].$post({ + json: { token }, + })) as unknown as Response; + if (res.ok) return { ok: true as const }; + return { ok: false as const, error: "We couldn't confirm your opt-in. The link may have expired." }; + } catch { + return { ok: false as const, error: "Service unavailable. Please try again later." }; + } +} + +/* ------------------------------------------------------------------ */ +/* Page */ +/* ------------------------------------------------------------------ */ + +export default function SmsOptinPage() { + const { data } = useLoaderData(); + const actionData = useActionData(); + const navigation = useNavigation(); + const submitting = navigation.state === "submitting"; + + if (!data) { + return ( +
+
+

Link not found

+

+ This opt-in link is invalid or has expired. If you'd still like text + updates, please contact your inspection company. +

+
+
+ ); + } + + if (actionData?.ok) { + return ( +
+
+

You're subscribed

+

+ You'll receive appointment and report updates from {data.companyName} by + text. Reply STOP anytime to opt out. +

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

Text me updates

+

+ Get appointment reminders and report-ready alerts from{" "} + {data.companyName} by text message. +

+
+

{data.disclosureText}

+
+ {actionData?.error && ( +

+ {actionData.error} +

+ )} +
+ +
+

+ Message & data rates may apply. Reply STOP to opt out. +

+
+
+ ); +} diff --git a/app/routes/settings-automations.tsx b/app/routes/settings-automations.tsx index 9d0d5fc8..4d45a6ef 100644 --- a/app/routes/settings-automations.tsx +++ b/app/routes/settings-automations.tsx @@ -11,10 +11,13 @@ export function meta() { interface Rule { id: string; name: string; trigger: string; recipient: string; delayMinutes: number; subjectTemplate: string; bodyTemplate: string; - conditions: string | null; channel: string; active: boolean; isDefault: boolean; + // Track L: channels[] supersedes the dead `channel` shadow; sms_body added. + // (Full multi-channel editor lands in Task 9; these keep the page type-safe.) + conditions: string | null; channels: string[]; smsBody: string | null; + active: boolean; isDefault: boolean; } interface Svc { id: string; name: string; } -interface LogRow { id: string; recipientEmail: string; sendAt: string; status: string; error: string | null; } +interface LogRow { id: string; recipient: string; channel: string; sendAt: string; status: string; error: string | null; } export const TRIGGER_LABELS: Record = { "inspection.created": "Inspection created", @@ -99,7 +102,8 @@ export async function action({ request, context }: Route.ActionArgs) { delayMinutes: Number(form.get("delayMinutes") ?? 0), subjectTemplate: String(form.get("subjectTemplate") ?? ""), bodyTemplate: String(form.get("bodyTemplate") ?? ""), - channel: "email", + // Track L: email-only until the Task 9 multi-channel editor lands. + channels: ["email"], conditions, }; const id = String(form.get("id") ?? ""); @@ -158,7 +162,7 @@ export default function SettingsAutomations() {

{rule.name}

{rule.isDefault && Default} - {rule.channel === "sms" && SMS} + {(rule.channels ?? []).includes("sms") &&SMS}

{TRIGGER_LABELS[rule.trigger] || rule.trigger} → {rule.recipient}

@@ -186,7 +190,7 @@ export default function SettingsAutomations() {
{recentLogs.map((l) => (
- {l.recipientEmail} + {l.recipient} {new Date(l.sendAt).toLocaleString()} = { @@ -783,6 +803,22 @@ export const bookingsRoutes = createApiRouter() licenseNumber: inspector.licenseNumber ?? null, slug: inspector.slug ?? null, } : undefined; + // Track L (D6, path B) — double-opt-in link injected at the RENDERER + // level (not gated on any automation rule) so disabling a rule never + // removes the only opt-in path. The token self-describes (tenant, + // contact) — see lib/sms/optin-token.ts. Best-effort: a token failure + // simply omits the link. + let smsOptinUrl: string | undefined; + if (bookingClientContactId && c.env.JWT_SECRET) { + try { + const { mintOptinToken } = await import('../lib/sms/optin-token'); + const token = await mintOptinToken(tenantId, bookingClientContactId, c.env.JWT_SECRET); + smsOptinUrl = `${getBaseUrl(c)}/sms-optin/${encodeURIComponent(token)}`; + } catch (e) { + logger.warn('booking.sms-optin.mint.failed', { inspectionId, error: e instanceof Error ? e.message : String(e) }); + } + } + await emailService.sendBookingConfirmation( body.clientEmail, body.clientName, @@ -801,6 +837,7 @@ export const bookingsRoutes = createApiRouter() }, sigInspector, getBookingHost(c), + smsOptinUrl, ).catch(e => logger.error('Booking confirmation email failed', {}, e instanceof Error ? e : undefined)); })()); diff --git a/server/api/sms.ts b/server/api/sms.ts new file mode 100644 index 00000000..66611fe4 --- /dev/null +++ b/server/api/sms.ts @@ -0,0 +1,296 @@ +/** + * Track L (D6/D9) — SMS consent capture + inbound STOP/START webhook. + * + * Public router (`smsPublicRoutes`, mounted /api/public): + * - GET /sms/optin-resolve?token=… — resolve an opt-in link token to the + * current disclosure + company name (the SSR opt-in page renders this). + * - POST /sms/optin-confirm {token} — record a `granted` event (optin_link). + * - POST /sms/inbound — platform shared-number webhook (D9 shape 1). + * - POST /sms/inbound/:tenant — tenant-scoped webhook (D9 shape 2). + * The inbound routes are plain Hono `.post` (form-encoded; validated by the + * Twilio request signature, not a zod body) and are NOT part of the typed + * BFF client — Twilio calls them directly. + * + * Admin router (`smsAdminRoutes`, mounted /api/admin, requireRole owner/admin): + * - POST /sms/attest {inspectionId} — inspector attestation (admin) → granted. + * - POST /sms/test {to} — one-off test send via resolved creds. + * - GET /sms/consent?inspectionId= — latest client consent for the inspection. + * + * Opt-in token mechanism (Step 0 decision): NO new table. The token is a + * self-describing sealed payload — `~sealToken(contactId)` — resolved + * via lib/sms/optin-token.ts (reuses the config-crypto tier-2 envelope, same as + * agreement tokens). See that file for the format rationale. + */ +import { createRoute, z } from '@hono/zod-openapi'; +import { createApiRouter } from '../lib/openapi-router'; +import { drizzle } from 'drizzle-orm/d1'; +import { and, eq } from 'drizzle-orm'; +import { contacts, inspections, tenants, tenantConfigs } from '../lib/db/schema'; +import { requireRole } from '../lib/middleware/rbac'; +import { auditFromContext } from '../lib/audit'; +import { withMcpMetadata } from '../lib/route-metadata-standards'; +import { Errors } from '../lib/errors'; +import { SmsConsentService } from '../services/sms-consent.service'; +import { ensureClientContact } from '../lib/sms/ensure-client-contact'; +import { resolveOptinToken } from '../lib/sms/optin-token'; +import { normalizeE164 } from '../lib/sms/phone'; +import { validateTwilioSignature, sendTwilioSms } from '../lib/sms/send-sms'; +import { loadTwilioForTenant } from '../lib/sms/resolve-twilio'; +import { loadTenantSecrets } from '../lib/secrets-cache'; +import { + SmsOptinResolveSchema, SmsOptinConfirmSchema, SmsAttestSchema, SmsTestSendSchema, SmsConsentQuerySchema, +} from '../lib/validations/sms.schema'; +import { getBaseUrl } from '../lib/url'; +import type { Context } from 'hono'; +import type { HonoConfig } from '../types/hono'; + +// STOP-set → revoke; START-set → grant; anything else is logged, not a state change. +const STOP_WORDS = new Set(['STOP', 'STOPALL', 'UNSUBSCRIBE', 'CANCEL', 'END', 'QUIT']); +const START_WORDS = new Set(['START', 'UNSTOP', 'YES']); + +// ─── Public router ─────────────────────────────────────────────────────────── + +const optinResolveRoute = createRoute(withMcpMetadata({ + method: 'get', + path: '/sms/optin-resolve', + tags: ['public', 'sms'], + summary: 'Resolve an SMS opt-in link token to disclosure + company name', + request: { query: SmsOptinResolveSchema }, + responses: { + 200: { + content: { 'application/json': { schema: z.object({ + success: z.literal(true), + data: z.object({ + companyName: z.string(), + disclosureText: z.string(), + }), + }) } }, + description: 'Resolved opt-in context', + }, + }, + operationId: 'resolveSmsOptin', + description: 'Resolves the opaque opt-in token to the company name + current SMS disclosure for the public double-opt-in page. Returns 404 on a bad/expired token.', +}, { scopes: ['read'], tier: 'extended' })); + +const optinConfirmRoute = createRoute(withMcpMetadata({ + method: 'post', + path: '/sms/optin-confirm', + tags: ['public', 'sms'], + summary: 'Confirm SMS opt-in (double opt-in) — records a granted consent event', + request: { body: { content: { 'application/json': { schema: SmsOptinConfirmSchema } } } }, + responses: { + 200: { content: { 'application/json': { schema: z.object({ success: z.literal(true) }) } }, description: 'Consent recorded' }, + }, + operationId: 'confirmSmsOptin', + description: 'Records a granted SMS consent event (captured_via=optin_link) for the contact encoded in the token. Idempotent — confirming twice simply appends a second granted event.', +}, { scopes: ['write'], tier: 'extended' })); + +export const smsPublicRoutes = createApiRouter() + .openapi(optinResolveRoute, async (c) => { + const { token } = c.req.valid('query'); + const resolved = await resolveOptinToken(token, c.env.JWT_SECRET, c.env.JWT_SECRET_PREVIOUS); + if (!resolved) throw Errors.NotFound('This opt-in link is invalid or has expired.'); + + const db = drizzle(c.env.DB); + const tenant = await db.select({ name: tenants.name }).from(tenants) + .where(eq(tenants.id, resolved.tenantId)).get(); + if (!tenant) throw Errors.NotFound('This opt-in link is invalid or has expired.'); + + const disc = await new SmsConsentService(c.env.DB).currentDisclosure(); + const disclosureText = (disc?.text ?? 'By confirming, you agree to receive appointment and report text messages. Message and data rates may apply. Reply STOP to opt out.') + .replace(/\{\{\s*company_name\s*\}\}/g, tenant.name); + return c.json({ success: true as const, data: { companyName: tenant.name, disclosureText } }, 200); + }) + .openapi(optinConfirmRoute, async (c) => { + const { token } = c.req.valid('json'); + const resolved = await resolveOptinToken(token, c.env.JWT_SECRET, c.env.JWT_SECRET_PREVIOUS); + if (!resolved) throw Errors.NotFound('This opt-in link is invalid or has expired.'); + + await new SmsConsentService(c.env.DB).record(resolved.tenantId, resolved.contactId, 'granted', 'optin_link', { + ip: c.req.header('CF-Connecting-IP'), + userAgent: c.req.header('User-Agent'), + }); + return c.json({ success: true as const }, 200); + }); + +// Inbound webhook — plain Hono routes (form-encoded, signature-validated). Not +// in the typed client; Twilio posts here directly. +smsPublicRoutes.post('/sms/inbound', (c) => + handleInbound(c, { authToken: c.env.TWILIO_AUTH_TOKEN ?? '', scopeTenantId: null })); + +smsPublicRoutes.post('/sms/inbound/:tenant', async (c) => { + const slug = c.req.param('tenant'); + const db = drizzle(c.env.DB); + const tenant = await db.select({ id: tenants.id }).from(tenants).where(eq(tenants.slug, slug)).get(); + if (!tenant) return c.text('', 404); + // The tenant's OWN Twilio auth token (when they self-configured); fall back to + // the platform token for a standalone single-tenant deploy that uses env creds. + let authToken = c.env.TWILIO_AUTH_TOKEN ?? ''; + try { + const dec = await loadTenantSecrets( + c.env.DB, c.env.TENANT_CACHE, tenant.id, c.env.JWT_SECRET, c.env.JWT_SECRET_PREVIOUS, + ); + const own = (dec as Record | null)?.['TWILIO_AUTH_TOKEN']; + if (own) authToken = own; + } catch { /* fall back to platform token */ } + return handleInbound(c, { authToken, scopeTenantId: tenant.id }); +}); + +/** + * Shared inbound handler. Validates the Twilio request signature against + * `APP_BASE_URL + path`, then applies STOP/START to the matching contact(s). + * scopeTenantId=null → platform shape (all platform-mode tenants matching From); + * scopeTenantId set → tenant-scoped shape (that tenant only). + */ +async function handleInbound( + c: Context, opts: { authToken: string; scopeTenantId: string | null }, +): Promise { + if (!opts.authToken) return c.text('', 403); + + let form: FormData; + try { form = await c.req.formData(); } catch { return c.text('', 400); } + const params: Record = {}; + for (const [k, v] of form.entries()) params[k] = typeof v === 'string' ? v : ''; + + const url = `${getBaseUrl(c)}${c.req.path}`; + const presented = c.req.header('X-Twilio-Signature') ?? ''; + const ok = await validateTwilioSignature(opts.authToken, url, params, presented); + if (!ok) return c.text('', 403); + + const from = normalizeE164(params.From ?? ''); + const cmd = (params.Body ?? '').trim().toUpperCase(); + const isRevoke = STOP_WORDS.has(cmd); + const isGrant = START_WORDS.has(cmd); + + if (!from) return c.text('', 200, { 'Content-Type': 'text/xml' }); + + const db = drizzle(c.env.DB); + // Pull candidate contacts (filtered to a tenant, or all platform-mode tenants), + // then match on the NORMALIZED phone (stored phones may be unnormalized). + const candidateRows = await db + .select({ id: contacts.id, tenantId: contacts.tenantId, phone: contacts.phone }) + .from(contacts) + .where(opts.scopeTenantId ? eq(contacts.tenantId, opts.scopeTenantId) : undefined) + .all(); + + // For the platform shape, restrict to tenants in platform SMS mode (or unset). + let allowedTenant: ((tenantId: string) => boolean) = () => true; + if (!opts.scopeTenantId) { + const cfgs = await db.select({ tenantId: tenantConfigs.tenantId, smsMode: tenantConfigs.smsMode }) + .from(tenantConfigs).all(); + const ownTenants = new Set(cfgs.filter((r) => r.smsMode === 'own').map((r) => r.tenantId)); + allowedTenant = (tid: string) => !ownTenants.has(tid); + } + + const matched = candidateRows.filter((r) => + normalizeE164(r.phone) === from && allowedTenant(r.tenantId)); + + const consentSvc = new SmsConsentService(c.env.DB); + for (const row of matched) { + if (isRevoke || isGrant) { + await consentSvc.record(row.tenantId, row.id, isRevoke ? 'revoked' : 'granted', 'admin', {}); + } + // NOTE: a non-command inbound body is acknowledged but NOT persisted as an + // automation_logs row — that table's automation_id/inspection_id are NOT + // NULL (and the no-FK-references rule forbids fabricating them), and an + // inbound reply is tied to neither. Two-way conversation surfacing is + // explicitly out of scope (spec §10); STOP/START consent sync is the only + // in-scope inbound behavior, and it is handled above. + } + return c.text('', 200, { 'Content-Type': 'text/xml' }); +} + +// ─── Admin router ───────────────────────────────────────────────────────────── + +const attestRoute = createRoute(withMcpMetadata({ + method: 'post', + path: '/sms/attest', + tags: ['admin', 'sms'], + summary: 'Inspector attestation — confirm the client agreed to receive texts', + middleware: [requireRole(['owner', 'admin'])], + request: { body: { content: { 'application/json': { schema: SmsAttestSchema } } } }, + responses: { + 200: { content: { 'application/json': { schema: z.object({ success: z.literal(true) }) } }, description: 'Consent recorded' }, + }, + operationId: 'attestSmsConsent', + description: 'Records a granted SMS consent event (captured_via=admin) for the inspection client contact, auto-creating + linking a contact when the client was free-typed (D6b). The deliberate, accountable basis for phone/in-person bookings.', +}, { scopes: ['admin'], tier: 'extended' })); + +const testSendRoute = createRoute(withMcpMetadata({ + method: 'post', + path: '/sms/test', + tags: ['admin', 'sms'], + summary: 'Send a one-off test SMS using the resolved Twilio creds', + middleware: [requireRole(['owner', 'admin'])], + request: { body: { content: { 'application/json': { schema: SmsTestSendSchema } } } }, + responses: { + 200: { content: { 'application/json': { schema: z.object({ success: z.boolean(), error: z.string().optional() }) } }, description: 'Send result' }, + }, + operationId: 'testSmsSend', + description: 'Sends a one-off test SMS to the supplied number using the tenant-resolved Twilio credentials (platform env or tenant own). Fail-closed: returns success=false when no creds resolve or the number is unparseable.', +}, { scopes: ['admin'], tier: 'extended' })); + +const consentStatusRoute = createRoute(withMcpMetadata({ + method: 'get', + path: '/sms/consent', + tags: ['admin', 'sms'], + summary: 'Latest SMS consent status for an inspection client', + middleware: [requireRole(['owner', 'admin', 'inspector'])], + request: { query: SmsConsentQuerySchema }, + responses: { + 200: { content: { 'application/json': { schema: z.object({ + success: z.literal(true), + data: z.object({ consent: z.enum(['granted', 'revoked', 'none']) }), + }) } }, description: 'Consent status' }, + }, + operationId: 'getSmsConsentStatus', + description: 'Returns the latest SMS consent action for the inspection client contact (granted/revoked/none) for the inspection-view status display.', +}, { scopes: ['admin'], tier: 'extended' })); + +export const smsAdminRoutes = createApiRouter() + .openapi(attestRoute, async (c) => { + const tenantId = c.get('tenantId') as string; + const { inspectionId } = c.req.valid('json'); + const db = drizzle(c.env.DB); + const insp = await db.select().from(inspections) + .where(and(eq(inspections.id, inspectionId), eq(inspections.tenantId, tenantId))).get(); + if (!insp) throw Errors.NotFound('Inspection not found.'); + + const contactId = await ensureClientContact(c.env.DB, tenantId, insp); + if (!contactId) throw Errors.BadRequest('This inspection has no client to attest consent for.'); + + await new SmsConsentService(c.env.DB).record(tenantId, contactId, 'granted', 'admin', { + ip: c.req.header('CF-Connecting-IP'), + userAgent: c.req.header('User-Agent'), + }); + auditFromContext(c, 'sms.consent.attest', 'inspection', { entityId: inspectionId, metadata: { contactId } }); + return c.json({ success: true as const }, 200); + }) + .openapi(testSendRoute, async (c) => { + const tenantId = c.get('tenantId') as string; + const { to } = c.req.valid('json'); + const normalized = normalizeE164(to); + if (!normalized) return c.json({ success: false, error: 'That phone number could not be parsed. Use an E.164 or US 10-digit format.' }, 200); + + const creds = await loadTwilioForTenant(c.env, tenantId); + if (!creds) return c.json({ success: false, error: 'SMS is not configured. Set your Twilio credentials first.' }, 200); + + const res = await sendTwilioSms(creds, normalized, 'This is a test message from your inspection company. SMS is configured correctly.'); + auditFromContext(c, 'sms.test_send', 'tenant', { metadata: { ok: res.ok } }); + return res.ok ? c.json({ success: true }, 200) : c.json({ success: false, error: res.error }, 200); + }) + .openapi(consentStatusRoute, async (c) => { + const tenantId = c.get('tenantId') as string; + const { inspectionId } = c.req.valid('query'); + const db = drizzle(c.env.DB); + const insp = await db.select({ clientContactId: inspections.clientContactId }).from(inspections) + .where(and(eq(inspections.id, inspectionId), eq(inspections.tenantId, tenantId))).get(); + if (!insp) throw Errors.NotFound('Inspection not found.'); + + const contactId = insp.clientContactId; + const latest = contactId ? await new SmsConsentService(c.env.DB).getLatest(tenantId, contactId) : null; + return c.json({ success: true as const, data: { consent: latest ?? 'none' } }, 200); + }); + +export type SmsPublicApi = typeof smsPublicRoutes; +export type SmsAdminApi = typeof smsAdminRoutes; diff --git a/server/index.ts b/server/index.ts index 374fcec7..556b3943 100644 --- a/server/index.ts +++ b/server/index.ts @@ -46,6 +46,7 @@ import tenantPresenceRoutes from './api/tenant-presence'; import inspectionPrefsRoutes from './api/inspection-prefs'; import aiRoutes from './api/ai'; import bookingsRoutes from './api/bookings'; +import { smsPublicRoutes, smsAdminRoutes } from './api/sms'; import adminRoutes from './api/admin'; import adminBrandingRoutes from './api/admin/branding'; import secretsRoutes from './api/secrets'; @@ -466,6 +467,8 @@ const routes = app .route('/api/public', repairRequestRoutes) // UC-C-7 — public share-token mint (customer Forward report flow). .route('/api/public', publicShareRoutes) + // Track L (D6/D9) — public SMS opt-in resolve/confirm + inbound STOP/START webhook. + .route('/api/public', smsPublicRoutes) .route('/api/admin', adminRoutes) // Branding sub-router — extracted to fix hono/client type-collapse (C-10) .route('/api/admin', adminBrandingRoutes) @@ -473,6 +476,8 @@ const routes = app .route('/api/admin', evidenceRoutes) // Secret UI化 — GET/PUT/POST /api/admin/secrets for all 14 integration keys .route('/api/admin', secretsRoutes) + // Track L — authed SMS consent attestation + test-send + consent status. + .route('/api/admin', smsAdminRoutes) // Email-template CRUD + preview — GET/PUT/POST /api/admin/email-templates .route('/api/admin', emailTemplateRoutes) .route('/api/agent', agentRoutes) diff --git a/server/lib/audit.ts b/server/lib/audit.ts index 5649e1b3..b0176d1e 100644 --- a/server/lib/audit.ts +++ b/server/lib/audit.ts @@ -72,7 +72,9 @@ export type AuditAction = | 'inspection.property_facts.autofill' | 'inspection.template_snapshot.update' | 'inspection.rating_system.switch' - | 'admin.migrate_finding_keys'; + | 'admin.migrate_finding_keys' + | 'sms.consent.attest' + | 'sms.test_send'; export interface AuditParams { db: D1Database; diff --git a/server/lib/route-metadata-standards.ts b/server/lib/route-metadata-standards.ts index 1d3636bd..049321ca 100644 --- a/server/lib/route-metadata-standards.ts +++ b/server/lib/route-metadata-standards.ts @@ -16,6 +16,7 @@ export const VALID_TAGS = [ 'audit', 'marketplace', 'recommendations', 'agreements', 'webhooks', 'public', 'calendar', 'tags', 'ratings', 'guest', 'profile', 'identity', 'automations', 'integrations', 'qbo', + 'sms', ] as const; export type ValidTag = typeof VALID_TAGS[number]; diff --git a/server/lib/sms/optin-token.ts b/server/lib/sms/optin-token.ts new file mode 100644 index 00000000..21ec7c72 --- /dev/null +++ b/server/lib/sms/optin-token.ts @@ -0,0 +1,42 @@ +import { sealToken, openToken } from '../config-crypto'; + +/** + * Track L (D6, path B) — self-describing SMS opt-in link token. DECISION: no new + * DB table. We reuse the existing tier-2 token-sealing helper (`sealToken`/ + * `openToken` in config-crypto, the same AES-GCM/HKDF envelope agreement tokens + * use). The token is `~`: + * - the tenantId is carried in cleartext (tenant ids are not secret) so the + * server can pick the AAD/KEK to open the sealed part; + * - the contactId is sealed under that tenant's AAD, so tampering with EITHER + * segment fails decryption (the AAD binds the ciphertext to the tenant). + * No lookup row is needed: resolving the token yields (tenantId, contactId) + * directly. The `~` delimiter never collides with the sealed blob, which is the + * colon-delimited `t1::` form. + */ + +const DELIM = '~'; + +export async function mintOptinToken( + tenantId: string, contactId: string, jwtSecret: string, +): Promise { + const sealed = await sealToken(contactId, tenantId, jwtSecret); + return `${tenantId}${DELIM}${sealed}`; +} + +/** Returns { tenantId, contactId } or null on any format/AAD/key mismatch. */ +export async function resolveOptinToken( + token: string, jwtSecret: string, jwtSecretPrevious?: string, +): Promise<{ tenantId: string; contactId: string } | null> { + const idx = token.indexOf(DELIM); + if (idx <= 0) return null; + const tenantId = token.slice(0, idx); + const sealed = token.slice(idx + 1); + if (!tenantId || !sealed) return null; + try { + const contactId = await openToken(sealed, tenantId, jwtSecret, jwtSecretPrevious); + if (!contactId) return null; + return { tenantId, contactId }; + } catch { + return null; + } +} diff --git a/server/lib/validations/booking.schema.ts b/server/lib/validations/booking.schema.ts index 65210c7f..050f91b9 100644 --- a/server/lib/validations/booking.schema.ts +++ b/server/lib/validations/booking.schema.ts @@ -42,6 +42,9 @@ export const PublicBookingSchema = z.object({ // this tenant, and persists the linked inspectorContactId on // inspections.referredByAgentId. agentRefSlug: z.string().min(2).max(64).optional().openapi({ example: 'jane-tester' }).describe('TODO describe agentRefSlug field for the OpenInspection MCP integration'), + // Track L (D6, path A) — unchecked SMS opt-in. When true, the submit records a + // `granted` SMS consent event (captured_via=booking_form) for the client contact. + smsOptin: z.boolean().optional().openapi({ example: false }).describe('Client self-book SMS opt-in (TCPA consent). When true, records a granted consent event.'), }).refine( (data) => data.timeSlot !== 'custom' || !!data.customTime, { message: 'customTime is required when timeSlot is custom', path: ['customTime'] }, diff --git a/server/lib/validations/sms.schema.ts b/server/lib/validations/sms.schema.ts new file mode 100644 index 00000000..785be09b --- /dev/null +++ b/server/lib/validations/sms.schema.ts @@ -0,0 +1,23 @@ +import { z } from '@hono/zod-openapi'; + +export const SmsOptinConfirmSchema = z.object({ + token: z.string().min(1).describe('Opaque SMS opt-in link token encoding (tenant, contact).'), +}).openapi('SmsOptinConfirm'); + +export const SmsOptinResolveSchema = z.object({ + token: z.string().min(1).describe('Opaque SMS opt-in link token encoding (tenant, contact).'), +}).openapi('SmsOptinResolve'); + +export const SmsAttestSchema = z.object({ + inspectionId: z.string().min(1).describe('Inspection whose client contact is being attested.'), +}).openapi('SmsAttest'); + +export const SmsTestSendSchema = z.object({ + to: z.string().min(3).describe('Destination phone (any format; normalized server-side).'), +}).openapi('SmsTestSend'); + +export const SmsConsentQuerySchema = z.object({ + inspectionId: z.string().min(1).describe('Inspection whose client consent status is requested.'), +}).openapi('SmsConsentQuery'); + +// Twilio inbound webhook is application/x-www-form-urlencoded; validated by signature, not zod-body. diff --git a/server/services/email.service.ts b/server/services/email.service.ts index d73d77e0..be75d01b 100644 --- a/server/services/email.service.ts +++ b/server/services/email.service.ts @@ -540,6 +540,7 @@ export class EmailService { icsEvent?: IcsEvent, inspector?: SignatureUser, host?: string, + smsOptinUrl?: string, ) { const attachments = icsEvent ? [this.icsAttachment(icsEvent)] : undefined; const calendarHint = icsEvent @@ -566,10 +567,18 @@ export class EmailService { html: appendSignature(fallbackBody, inspector, host), }, signatureHtml ? { signatureHtml } : undefined); if (!rendered.enabled) return; + // Track L (D6, path B) — append the SMS double-opt-in link. Injected here + // (renderer level) so it survives template overrides and is never gated on + // a specific automation rule being enabled. + const optinBlock = smsOptinUrl + ? `

+ Prefer text updates? Also text me appointment & report updates. Message & data rates may apply; reply STOP to opt out. +

` + : ''; await this.sendEmail( [to], rendered.subject, - rendered.html, + optinBlock ? `${rendered.html}${optinBlock}` : rendered.html, attachments, { inspector }, ); diff --git a/server/services/sms-consent.service.ts b/server/services/sms-consent.service.ts index fa4e8443..f892a884 100644 --- a/server/services/sms-consent.service.ts +++ b/server/services/sms-consent.service.ts @@ -29,7 +29,7 @@ export class SmsConsentService { /** Append a consent event for a client contact, stamping the current disclosure version. */ async record( tenantId: string, contactId: string, action: ConsentAction, capturedVia: CapturedVia, - meta: { ip?: string; userAgent?: string }, + meta: { ip?: string | undefined; userAgent?: string | undefined }, ) { const db = this.getDrizzle(); const disc = await this.currentDisclosure(); diff --git a/server/types/hono.ts b/server/types/hono.ts index 571beb67..37c75659 100644 --- a/server/types/hono.ts +++ b/server/types/hono.ts @@ -80,6 +80,12 @@ export interface AppEnv { STRIPE_SECRET_KEY?: string; STRIPE_WEBHOOK_SECRET?: string; + // Track L — Twilio SMS (platform-default in SaaS; merged from tenant secrets + // by integrationSecretsMiddleware for BYO/standalone). + TWILIO_ACCOUNT_SID?: string; + TWILIO_AUTH_TOKEN?: string; + TWILIO_FROM_NUMBER?: string; + // Rate Limiting RATE_LIMITER?: { limit(options: { key: string }): Promise<{ success: boolean }> }; diff --git a/tests/unit/sms-api.spec.ts b/tests/unit/sms-api.spec.ts new file mode 100644 index 00000000..fe0a2353 --- /dev/null +++ b/tests/unit/sms-api.spec.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { OpenAPIHono } from '@hono/zod-openapi'; +import { createTestDb, setupSchema } from './db'; +import * as schema from '../../server/lib/db/schema'; +import type { HonoConfig } from '../../server/types/hono'; +import { AppError } from '../../server/lib/errors'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; + +/** + * Track L Task 8 — SMS consent API (in-process Hono harness, mirrors + * agreement-public-routes.spec.ts): mock drizzle-orm/d1 → test sqlite, mount the + * sms routers on an OpenAPIHono app, inject tenantId/user via middleware, drive + * app.request(). The SmsConsentService also calls drizzle(c.env.DB) → the same + * mocked instance. + */ +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; + +// Imported AFTER the mock is registered. +// eslint-disable-next-line import/order +import { smsPublicRoutes, smsAdminRoutes } from '../../server/api/sms'; +import { SmsConsentService } from '../../server/services/sms-consent.service'; +import { signParams } from '../../server/lib/sms/send-sms'; + +const TENANT = '00000000-0000-0000-0000-000000000001'; +const OTHER_TENANT = '00000000-0000-0000-0000-000000000002'; +const APP_BASE_URL = 'https://app.example.test'; +const PLATFORM_TOKEN = 'platform-auth-token'; + +const FAKE_ENV = { + DB: {}, + APP_BASE_URL, + JWT_SECRET: 'test-secret', + TWILIO_AUTH_TOKEN: PLATFORM_TOKEN, + TENANT_CACHE: { get: async () => null, put: async () => {} }, +} as unknown as HonoConfig['Bindings']; + +function makeExecCtx() { + const ctx = { waitUntil: () => {}, passThroughOnException: () => {} } as unknown as ExecutionContext; + return ctx; +} + +function buildApp(db: BetterSQLite3Database) { + 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_error', message: String(err) } }, 500); + }); + // Inject an owner identity so requireRole(['owner','admin']) passes for admin routes. + app.use('*', async (c, next) => { + c.set('tenantId', TENANT); + c.set('userRole', 'owner'); + c.set('user', { sub: 'user-1', role: 'owner', tenantId: TENANT } as never); + await next(); + }); + app.route('/api/public', smsPublicRoutes); + app.route('/api/admin', smsAdminRoutes); + (mockDrizzle as unknown as ReturnType).mockReturnValue(db); + return app; +} + +async function seedTenant(db: BetterSQLite3Database, id: string, slug: string) { + await db.insert(schema.tenants).values({ + id, name: `T-${slug}`, slug, status: 'active', + deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + } as never); +} + +let db: BetterSQLite3Database; +let sqlite: { close: () => void }; + +beforeEach(async () => { + const fx = createTestDb(); + db = fx.db as BetterSQLite3Database; + sqlite = fx.sqlite; + await setupSchema(fx.sqlite); + (mockDrizzle as unknown as ReturnType).mockReturnValue(db); + await seedTenant(db, TENANT, 'acme'); + await new SmsConsentService({} as D1Database).publishDisclosure('disclosure v1'); +}); + +afterEach(() => sqlite.close()); + +function form(fields: Record): RequestInit { + const body = new URLSearchParams(fields).toString(); + return { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body }; +} + +describe('SMS consent API (Track L Task 8)', () => { + it('inspector attestation records granted for an already-linked client contact', async () => { + const contactId = crypto.randomUUID(); + await db.insert(schema.contacts).values({ + id: contactId, tenantId: TENANT, type: 'client', name: 'Jane', email: 'jane@x.com', createdAt: new Date(), + } as never); + const inspId = crypto.randomUUID(); + await db.insert(schema.inspections).values({ + id: inspId, tenantId: TENANT, propertyAddress: '1 Main', clientName: 'Jane', + clientEmail: 'jane@x.com', clientContactId: contactId, date: '2026-07-01', + status: 'draft', paymentStatus: 'unpaid', price: 0, agreementRequired: false, paymentRequired: false, createdAt: new Date(), + } as never); + + const app = buildApp(db); + const res = await app.request('/api/admin/sms/attest', + { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ inspectionId: inspId }) }, + FAKE_ENV, makeExecCtx()); + expect(res.status).toBe(200); + expect(await new SmsConsentService({} as D1Database).getLatest(TENANT, contactId)).toBe('granted'); + const ev = await db.select().from(schema.smsConsentLog).get(); + expect(ev?.capturedVia).toBe('admin'); + }); + + it('attestation auto-creates + links a contact for a free-typed client (D6b)', async () => { + const inspId = crypto.randomUUID(); + await db.insert(schema.inspections).values({ + id: inspId, tenantId: TENANT, propertyAddress: '2 Oak', clientName: 'Bob', + clientEmail: 'bob@x.com', clientContactId: null, date: '2026-07-02', + status: 'draft', paymentStatus: 'unpaid', price: 0, agreementRequired: false, paymentRequired: false, createdAt: new Date(), + } as never); + + const app = buildApp(db); + const res = await app.request('/api/admin/sms/attest', + { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ inspectionId: inspId }) }, + FAKE_ENV, makeExecCtx()); + expect(res.status).toBe(200); + + const insp = await db.select().from(schema.inspections).where(eq(schema.inspections.id, inspId)).get(); + expect(insp?.clientContactId).toBeTruthy(); + expect(await new SmsConsentService({} as D1Database).getLatest(TENANT, insp!.clientContactId!)).toBe('granted'); + }); + + it('GET /sms/consent reports the latest action', async () => { + const contactId = crypto.randomUUID(); + await db.insert(schema.contacts).values({ + id: contactId, tenantId: TENANT, type: 'client', name: 'Jane', email: 'jane@x.com', createdAt: new Date(), + } as never); + const inspId = crypto.randomUUID(); + await db.insert(schema.inspections).values({ + id: inspId, tenantId: TENANT, propertyAddress: '1 Main', clientName: 'Jane', + clientContactId: contactId, date: '2026-07-01', status: 'draft', paymentStatus: 'unpaid', price: 0, + agreementRequired: false, paymentRequired: false, createdAt: new Date(), + } as never); + await new SmsConsentService({} as D1Database).record(TENANT, contactId, 'granted', 'booking_form', {}); + + const app = buildApp(db); + const res = await app.request(`/api/admin/sms/consent?inspectionId=${inspId}`, {}, FAKE_ENV, makeExecCtx()); + expect(res.status).toBe(200); + const body = await res.json() as { data: { consent: string } }; + expect(body.data.consent).toBe('granted'); + }); + + it('tenant-scoped inbound STOP (valid signature) → revoked for that tenant only', async () => { + const contactId = crypto.randomUUID(); + await db.insert(schema.contacts).values({ + id: contactId, tenantId: TENANT, type: 'client', name: 'Jane', phone: '+15551234567', createdAt: new Date(), + } as never); + await new SmsConsentService({} as D1Database).record(TENANT, contactId, 'granted', 'admin', {}); + + const params = { From: '+15551234567', Body: 'STOP' }; + const url = `${APP_BASE_URL}/api/public/sms/inbound/acme`; + const sig = await signParams(PLATFORM_TOKEN, url, params); + + const app = buildApp(db); + const res = await app.request('/api/public/sms/inbound/acme', + { ...form(params), headers: { 'content-type': 'application/x-www-form-urlencoded', 'X-Twilio-Signature': sig } }, + FAKE_ENV, makeExecCtx()); + expect(res.status).toBe(200); + expect(await new SmsConsentService({} as D1Database).getLatest(TENANT, contactId)).toBe('revoked'); + }); + + it('platform inbound STOP → revoked across platform-mode tenants matching From', async () => { + await seedTenant(db, OTHER_TENANT, 'beta'); + // beta is in 'own' SMS mode → must NOT be revoked by a platform-number STOP. + await db.insert(schema.tenantConfigs).values({ tenantId: OTHER_TENANT, smsMode: 'own', updatedAt: new Date() } as never); + + const cAcme = crypto.randomUUID(); + const cBeta = crypto.randomUUID(); + await db.insert(schema.contacts).values([ + { id: cAcme, tenantId: TENANT, type: 'client', name: 'Jane', phone: '(555) 123-4567', createdAt: new Date() }, + { id: cBeta, tenantId: OTHER_TENANT, type: 'client', name: 'Jane', phone: '+15551234567', createdAt: new Date() }, + ] as never); + const svc = new SmsConsentService({} as D1Database); + await svc.record(TENANT, cAcme, 'granted', 'admin', {}); + await svc.record(OTHER_TENANT, cBeta, 'granted', 'admin', {}); + + const params = { From: '+15551234567', Body: 'STOP' }; + const url = `${APP_BASE_URL}/api/public/sms/inbound`; + const sig = await signParams(PLATFORM_TOKEN, url, params); + + const app = buildApp(db); + const res = await app.request('/api/public/sms/inbound', + { ...form(params), headers: { 'content-type': 'application/x-www-form-urlencoded', 'X-Twilio-Signature': sig } }, + FAKE_ENV, makeExecCtx()); + expect(res.status).toBe(200); + expect(await svc.getLatest(TENANT, cAcme)).toBe('revoked'); // platform-mode → revoked + expect(await svc.getLatest(OTHER_TENANT, cBeta)).toBe('granted'); // own-mode → untouched + }); + + it('inbound with a bad signature → 403, no write', async () => { + const contactId = crypto.randomUUID(); + await db.insert(schema.contacts).values({ + id: contactId, tenantId: TENANT, type: 'client', name: 'Jane', phone: '+15551234567', createdAt: new Date(), + } as never); + await new SmsConsentService({} as D1Database).record(TENANT, contactId, 'granted', 'admin', {}); + + const params = { From: '+15551234567', Body: 'STOP' }; + const app = buildApp(db); + const res = await app.request('/api/public/sms/inbound', + { ...form(params), headers: { 'content-type': 'application/x-www-form-urlencoded', 'X-Twilio-Signature': 'wrong' } }, + FAKE_ENV, makeExecCtx()); + expect(res.status).toBe(403); + expect(await new SmsConsentService({} as D1Database).getLatest(TENANT, contactId)).toBe('granted'); + }); + + it('opt-in confirm via a sealed token → granted (optin_link)', async () => { + const contactId = crypto.randomUUID(); + await db.insert(schema.contacts).values({ + id: contactId, tenantId: TENANT, type: 'client', name: 'Jane', email: 'jane@x.com', createdAt: new Date(), + } as never); + const { mintOptinToken } = await import('../../server/lib/sms/optin-token'); + const token = await mintOptinToken(TENANT, contactId, FAKE_ENV.JWT_SECRET); + + const app = buildApp(db); + const res = await app.request('/api/public/sms/optin-confirm', + { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token }) }, + FAKE_ENV, makeExecCtx()); + expect(res.status).toBe(200); + const ev = await db.select().from(schema.smsConsentLog).get(); + expect(ev?.capturedVia).toBe('optin_link'); + expect(await new SmsConsentService({} as D1Database).getLatest(TENANT, contactId)).toBe('granted'); + }); + + it('opt-in resolve returns disclosure + company name', async () => { + const contactId = crypto.randomUUID(); + await db.insert(schema.contacts).values({ + id: contactId, tenantId: TENANT, type: 'client', name: 'Jane', email: 'jane@x.com', createdAt: new Date(), + } as never); + const { mintOptinToken } = await import('../../server/lib/sms/optin-token'); + const token = await mintOptinToken(TENANT, contactId, FAKE_ENV.JWT_SECRET); + + const app = buildApp(db); + const res = await app.request(`/api/public/sms/optin-resolve?token=${encodeURIComponent(token)}`, {}, FAKE_ENV, makeExecCtx()); + expect(res.status).toBe(200); + const body = await res.json() as { data: { companyName: string; disclosureText: string } }; + expect(body.data.companyName).toBe('T-acme'); + expect(body.data.disclosureText).toContain('disclosure'); + }); +}); diff --git a/tests/web/unit/settings-automations.spec.ts b/tests/web/unit/settings-automations.spec.ts index 0f3dd21b..29f28ec3 100644 --- a/tests/web/unit/settings-automations.spec.ts +++ b/tests/web/unit/settings-automations.spec.ts @@ -160,7 +160,9 @@ describe('settings-automations action', () => { })); expect(postAutomation).toHaveBeenCalledTimes(1); const arg = postAutomation.mock.calls[0][0]; - expect(arg.json.channel).toBe('email'); + // Track L: the save payload sends channels[] (email-only) instead of the + // retired singular `channel`. Full multi-channel editor lands in Task 9. + expect(arg.json.channels).toEqual(['email']); expect(arg.json.conditions).toEqual({ requirePaid: true, serviceIds: ['svc-1', 'svc-2'] }); expect(res).toEqual({ ok: true, error: undefined }); }); From a164c643a00007c0187b4ea00fc0d98de69e9988 Mon Sep 17 00:00:00 2001 From: important-new Date: Tue, 9 Jun 2026 01:29:21 +0800 Subject: [PATCH 18/19] feat(sms): editor multi-channel + run-log badge + Settings SMS config + consent UI (Task 9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track L Task 9 + carried-over Task 8 review fixes: A. Parse channels on output — AutomationService.list/create/update now return `channels` as string[] (new private serializeRow) so the typed API surface (AutomationSchema.channels: string[]) is truthful end to end. B. Editor multi-channel (settings-automations.tsx) — channel checkboxes (Email/SMS) replacing the disabled select; conditional SMS body textarea with a live char counter + ~N segments hint; email subject/body section shown when Email is checked; >=1 channel enforced client-side (Save disabled + hint); save reads form.getAll("channels") + smsBody (nulled when SMS off); per-rule + run-log channel badge; run-log renders l.recipient. SMS placeholder palette. C. Residual recipientEmail rename — verified no automation-log read still uses recipientEmail (only renderer is settings-automations, already on l.recipient; inspection-hub:737 is an unrelated payment-modal prop). D. Settings -> Communication SMS section — sms_mode toggle + companyPhone via extended PATCH /api/admin/tenant-config (schema+handler+GET); effective-source line from new GET /api/admin/sms/config (resolveTwilioSource, no secrets); 3 Twilio SecretFields via existing PUT /api/admin/secrets; A2P 10DLC guidance; inbound webhook URL (own/standalone only); Send-test-SMS. E. Inspection-view consent + attest (inspection-hub.tsx) — ClientSmsConsent shows granted/revoked/not-recorded via GET /sms/consent; inline "I confirm" attest posts POST /sms/attest. BFF only. Tests: extended settings-automations.spec (channels/recipient fixtures + sms serialization), new settings-communication-sms + inspection-hub-sms-consent web specs, resolveTwilioSource + list()-parse unit cases. All gates green: unit 1604, web 361, workers 33, type-check 0. Co-Authored-By: Claude Opus 4.8 @ --- app/routes/inspection-hub.tsx | 85 +++++++- app/routes/settings-automations.tsx | 104 +++++++-- app/routes/settings-communication.tsx | 204 +++++++++++++++++- server/api/admin.ts | 12 ++ server/api/sms.ts | 44 +++- server/lib/sms/resolve-twilio.ts | 19 ++ server/services/automation.service.ts | 22 +- tests/unit/automation-channels.spec.ts | 19 +- tests/unit/automation-conditions.spec.ts | 5 +- tests/unit/sms-resolve-twilio.spec.ts | 21 +- .../unit/inspection-hub-sms-consent.spec.ts | 97 +++++++++ tests/web/unit/settings-automations.spec.ts | 43 +++- .../unit/settings-communication-sms.spec.ts | 126 +++++++++++ 13 files changed, 768 insertions(+), 33 deletions(-) create mode 100644 tests/web/unit/inspection-hub-sms-consent.spec.ts create mode 100644 tests/web/unit/settings-communication-sms.spec.ts diff --git a/app/routes/inspection-hub.tsx b/app/routes/inspection-hub.tsx index 8da80905..0576bd28 100644 --- a/app/routes/inspection-hub.tsx +++ b/app/routes/inspection-hub.tsx @@ -79,7 +79,16 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { } const body = await res.json(); const hub = ((body as Record).data ?? {}) as unknown as HubData; - return { hub }; + + // Track L (E) — client SMS consent status for the People card. Best-effort: + // a failure degrades to "none" (the attest affordance still renders). + const consentRes = await api.smsAdmin.sms.consent.$get({ query: { inspectionId: id } }).catch(() => null); + const smsConsent = + consentRes && consentRes.ok + ? (((await consentRes.json()) as { data?: { consent?: "granted" | "revoked" | "none" } }).data?.consent ?? "none") + : "none"; + + return { hub, smsConsent }; } /* ------------------------------------------------------------------ */ @@ -132,6 +141,20 @@ export async function action({ request, params, context }: Route.ActionArgs) { return { ok: true, intent: "request-payment" as const, error: undefined }; } + if (intent === "attest-sms") { + // Track L (E) — inspector attestation that the client agreed to receive texts. + const res = await api.smsAdmin.sms.attest.$post({ json: { inspectionId: id } }); + if (!res.ok) { + const err = (await res.json().catch(() => null)) as { error?: { message?: string } } | null; + return { + ok: false, + intent: "attest-sms" as const, + error: err?.error?.message ?? "Could not record consent. Please try again.", + }; + } + return { ok: true, intent: "attest-sms" as const, error: undefined }; + } + if (intent === "publish") { // theme: the editor's PublishModal posts no `theme`, so it rides the // schema default ('modern'). We send the same value explicitly here — @@ -178,10 +201,14 @@ function humanizeStatus(status: string): string { /* ------------------------------------------------------------------ */ export default function InspectionHubPage() { - const { hub } = useLoaderData(); + const { hub, smsConsent } = useLoaderData(); const { inspection, people, services, tenantSlug } = hub; const blocks = deriveBlockStates(hub); + // Track L (E) — SMS consent attestation. Dedicated fetcher (never share). + const attestSms = useFetcher(); + const attesting = attestSms.state !== "idle"; + // Send-agreement modal — its own dedicated fetcher (B-17: never share // fetchers between mutations). Close on success; the loader revalidation // refreshes agreementRequests automatically. @@ -341,6 +368,12 @@ export default function InspectionHubPage() { {people.client.phone} )} + {/* Track L (E) — SMS consent status + inspector attestation */} +
) : inspection.clientName ? ( // Bare-text fallback when only the denormalized name is present. @@ -772,6 +805,54 @@ function RequestPaymentModal({ ); } +/* ------------------------------------------------------------------ */ +/* Client SMS consent status + attestation (Track L) */ +/* ------------------------------------------------------------------ */ + +function ClientSmsConsent({ + consent, + fetcher, + attesting, +}: { + consent: "granted" | "revoked" | "none"; + fetcher: ReturnType>; + attesting: boolean; +}) { + const error = + fetcher.data?.intent === "attest-sms" && !fetcher.data.ok + ? fetcher.data.error + : undefined; + + const label = + consent === "granted" ? "granted" : consent === "revoked" ? "revoked" : "not recorded"; + const tone = + consent === "granted" ? "text-ih-ok-fg" : consent === "revoked" ? "text-ih-bad-fg" : "text-ih-fg-4"; + + return ( +
+ + Client SMS: {label} + + {/* Offer the attestation only when not already granted. Framed as an + inspector confirmation that the client agreed (not a consent-less + override) — the deliberate basis for phone/in-person bookings. */} + {consent !== "granted" && ( + + + + + )} + {error && {error}} +
+ ); +} + /* ------------------------------------------------------------------ */ /* Publish-report modal */ /* ------------------------------------------------------------------ */ diff --git a/app/routes/settings-automations.tsx b/app/routes/settings-automations.tsx index 4d45a6ef..c2479a47 100644 --- a/app/routes/settings-automations.tsx +++ b/app/routes/settings-automations.tsx @@ -37,6 +37,15 @@ export const TRIGGER_LABELS: Record = { }; const RECIPIENTS = ["client", "buying_agent", "selling_agent", "inspector", "all"] as const; const PLACEHOLDERS = ["client_name", "property_address", "scheduled_date", "report_url", "invoice_url", "payment_url", "company_name", "review_url"]; +// Track L — SMS bodies are plain text; the renderable var set differs from email +// (no subject, no HTML; company_phone is the SMS call-back number). +const SMS_PLACEHOLDERS = ["client_name", "property_address", "scheduled_date", "report_url", "review_url", "company_name", "company_phone"]; + +/** GSM-ish segment estimate: 160 chars for one segment, 153 for each concatenated part. */ +function smsSegments(len: number): number { + if (len === 0) return 0; + return len <= 160 ? 1 : Math.ceil(len / 153); +} interface Conditions { requirePaid?: boolean; requireSigned?: boolean; serviceIds?: string[]; } @@ -95,6 +104,10 @@ export async function action({ request, context }: Route.ActionArgs) { requireSigned: form.get("requireSigned") === "on", serviceIds, }); + // Track L (Task 9) — multi-channel: read the checked channels; ≥1 is enforced + // client-side (the Save button disables when none) AND server-side (zod .min(1)). + const channels = form.getAll("channels").map(String).filter((c) => c === "email" || c === "sms"); + const smsBody = String(form.get("smsBody") ?? "").trim(); const json = { name: String(form.get("name") ?? ""), trigger: String(form.get("trigger") ?? ""), @@ -102,8 +115,9 @@ export async function action({ request, context }: Route.ActionArgs) { delayMinutes: Number(form.get("delayMinutes") ?? 0), subjectTemplate: String(form.get("subjectTemplate") ?? ""), bodyTemplate: String(form.get("bodyTemplate") ?? ""), - // Track L: email-only until the Task 9 multi-channel editor lands. - channels: ["email"], + channels: channels.length ? channels : ["email"], + // Persist the SMS body only when SMS is an enabled channel; else clear it. + smsBody: channels.includes("sms") ? smsBody : null, conditions, }; const id = String(form.get("id") ?? ""); @@ -190,6 +204,8 @@ export default function SettingsAutomations() {
{recentLogs.map((l) => (
+ {l.channel ?? "email"} {l.recipient} {new Date(l.sendAt).toLocaleString()} { if (fetcher.state === "idle" && fetcher.data?.ok) onClose(); }, [fetcher.state, fetcher.data, onClose]); @@ -258,14 +286,21 @@ function AutomationEditor({ rule, services, onClose }: { rule: Rule | null; serv
-
+
Do this -
- + + {/* Channel multi-select + recipient + delay */} +
+
+ + +
- -