From cc332c3282e4b9db82e3f92fd4d07690e9645d74 Mon Sep 17 00:00:00 2001 From: important-new Date: Sat, 6 Jun 2026 21:51:54 +0800 Subject: [PATCH 01/13] 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/13] 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/13] 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/13] 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/13] 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 609cb77d5b4e017d55f21f73b4762defc3abc783 Mon Sep 17 00:00:00 2001 From: important-new Date: Mon, 8 Jun 2026 15:21:53 +0800 Subject: [PATCH 06/13] =?UTF-8?q?feat(automations):=20migration=200024=20?= =?UTF-8?q?=E2=80=94=20conditions=20+=20channel=20+=20review=5Furl;=20insp?= =?UTF-8?q?ection.reminder=20trigger;=20standalone=20cron?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - automations.conditions (TEXT NULL): send-time gates JSON evaluated at flush() - automations.channel (TEXT NOT NULL DEFAULT 'email'): delivery channel; SMS reserved for Track L - tenant_configs.review_url (TEXT NULL): per-company review link for Track J #122 - Widens trigger enum with 'inspection.reminder' (Track J D7, cron-fired) - wrangler.jsonc: adds triggers.crons ["*/5 * * * *"] so scheduled() runs on OSS deploys - Workers inline DDL synced (review_url added to both cmd-consumer + cmd-fixtures specs) Co-Authored-By: Claude Opus 4.8 --- migrations/0024_conscious_roughhouse.sql | 3 + migrations/meta/0024_snapshot.json | 7912 ++++++++++++++++++++++ migrations/meta/_journal.json | 7 + server/lib/db/schema/inspection.ts | 10 + server/lib/db/schema/tenant.ts | 3 + tests/workers/cmd-consumer.spec.ts | 2 +- tests/workers/cmd-fixtures.spec.ts | 2 +- wrangler.jsonc | 4 + 8 files changed, 7941 insertions(+), 2 deletions(-) create mode 100644 migrations/0024_conscious_roughhouse.sql create mode 100644 migrations/meta/0024_snapshot.json diff --git a/migrations/0024_conscious_roughhouse.sql b/migrations/0024_conscious_roughhouse.sql new file mode 100644 index 00000000..baf89b1c --- /dev/null +++ b/migrations/0024_conscious_roughhouse.sql @@ -0,0 +1,3 @@ +ALTER TABLE `automations` ADD `conditions` text;--> statement-breakpoint +ALTER TABLE `automations` ADD `channel` text DEFAULT 'email' NOT NULL;--> statement-breakpoint +ALTER TABLE `tenant_configs` ADD `review_url` text; \ No newline at end of file diff --git a/migrations/meta/0024_snapshot.json b/migrations/meta/0024_snapshot.json new file mode 100644 index 00000000..5490b14d --- /dev/null +++ b/migrations/meta/0024_snapshot.json @@ -0,0 +1,7912 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "4cdbb422-ddfd-4fcb-9d23-48debeacf5ee", + "prevId": "36d5ba94-833c-4cfd-a475-8e3a015252ad", + "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_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "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'" + }, + "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": {} + }, + "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'" + }, + "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 c114ed35..5877a1c1 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1780878457631, "tag": "0023_free_harpoon", "breakpoints": true + }, + { + "idx": 24, + "version": "6", + "when": 1780903233574, + "tag": "0024_conscious_roughhouse", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/lib/db/schema/inspection.ts b/server/lib/db/schema/inspection.ts index 69a00a20..d19e9e05 100644 --- a/server/lib/db/schema/inspection.ts +++ b/server/lib/db/schema/inspection.ts @@ -527,6 +527,10 @@ export const automations = sqliteTable('automations', { 'agreement.signer_signed', 'agreement.viewed', 'agreement.declined', 'agreement.expired', 'event.created', 'event.completed', + // Track J (D7) — the one time-relative trigger. Cron-fired by + // AutomationService.enqueueReminders(); delayMinutes is the lead + // time BEFORE inspections.date (not a post-event delay). + 'inspection.reminder', ], }).notNull(), recipient: text('recipient', { @@ -535,6 +539,12 @@ export const automations = sqliteTable('automations', { delayMinutes: integer('delay_minutes').notNull().default(0), subjectTemplate: text('subject_template').notNull(), bodyTemplate: text('body_template').notNull(), + // 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. + channel: text('channel', { enum: ['email', 'sms'] }).notNull().default('email'), active: integer('active', { mode: 'boolean' }).notNull().default(true), isDefault: integer('is_default', { mode: 'boolean' }).notNull().default(false), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), diff --git a/server/lib/db/schema/tenant.ts b/server/lib/db/schema/tenant.ts index 482b9e17..96f51a67 100644 --- a/server/lib/db/schema/tenant.ts +++ b/server/lib/db/schema/tenant.ts @@ -225,6 +225,9 @@ export const tenantConfigs = sqliteTable('tenant_configs', { senderDisplayName: text('sender_display_name'), useInspectorFromName: integer('use_inspector_from_name', { mode: 'boolean' }).notNull().default(false), billingUrl: text('billing_url'), + // 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'), 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/tests/workers/cmd-consumer.spec.ts b/tests/workers/cmd-consumer.spec.ts index 71e98701..f7377b36 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, 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, 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);', ); } diff --git a/tests/workers/cmd-fixtures.spec.ts b/tests/workers/cmd-fixtures.spec.ts index 5b47254a..dd56ac38 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, 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, 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);', ); }); diff --git a/wrangler.jsonc b/wrangler.jsonc index 9b33b819..7e05593b 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -13,6 +13,10 @@ "assets": { "directory": "./build/client", "binding": "ASSETS" }, "observability": { "enabled": true }, "upload_source_maps": true, + // Drives the scheduled() handler: automation flush + inspection.reminder + // enqueue (Track J) + agreement expiry + GDPR retention sweep. SaaS uses its + // own wrangler.saas.jsonc schedule; this is the standalone/OSS default. + "triggers": { "crons": ["*/5 * * * *"] }, "vars": { "ENVIRONMENT": "production", "APP_MODE": "standalone", From 314097423fa87f6516cf2a2c24e5794f8bf59258 Mon Sep 17 00:00:00 2001 From: important-new Date: Mon, 8 Jun 2026 15:34:27 +0800 Subject: [PATCH 07/13] feat(automations): zod for conditions/channel/inspection.reminder + reviewUrl tenant-config field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `inspection.reminder` to AUTOMATION_TRIGGERS (Track J D2) - Add AUTOMATION_CHANNELS enum ['email', 'sms'] with 'email' default - Add ConditionsSchema (.strict()): requirePaid / requireSigned / serviceIds - Wire conditions (nullish) + channel (default email) into CreateAutomationSchema - AutomationSchema (response): conditions (string nullable) + channel fields - UpdateAutomationSchema inherits both via .partial() — no change needed - TenantConfigPatchSchema: reviewUrl (url, max 500, nullish, Track J #122) - TenantConfigGetResponseSchema data.reviewUrl (string nullable optional) - tenantConfigPatchRoute handler: maps reviewUrl → null on empty/null - tenantConfigGetRoute handler: returns reviewUrl ?? null - 3 unit tests (automation-schema.spec.ts): all pass - type-check:api: 0 errors Co-Authored-By: Claude Opus 4.8 --- server/api/admin.ts | 6 +++++ server/lib/validations/automation.schema.ts | 14 ++++++++++ tests/unit/automation-schema.spec.ts | 30 +++++++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 tests/unit/automation-schema.spec.ts diff --git a/server/api/admin.ts b/server/api/admin.ts index 24acd543..7e207360 100644 --- a/server/api/admin.ts +++ b/server/api/admin.ts @@ -931,6 +931,7 @@ const TenantConfigGetResponseSchema = z.object({ blockUnsignedAgreement: z.boolean().describe('Whether unsigned agreements block inspection start'), allowInspectorChoice: z.boolean().describe('Whether the public booking page offers an inspector dropdown'), agreementRetentionYears: z.number().int().describe('Years signed agreements are retained before the GDPR retention sweep destroys them (Track I-a). Default 6.'), + reviewUrl: z.string().nullable().optional().describe('Track J (#122) — company review link, or null.'), }).describe('Current tenant configuration flags'), }).openapi('TenantConfigGetResponse'); @@ -962,6 +963,7 @@ const TenantConfigPatchSchema = z.object({ blockUnsignedAgreement: z.boolean().optional().describe('Whether clients must sign the inspection agreement before a booking is confirmed.'), allowInspectorChoice: z.boolean().optional().describe('Toggle the public inspector-choice dropdown (IA-26)'), agreementRetentionYears: z.number().int().min(1).max(99).optional().describe('How many years signed agreements / signatures are retained before the GDPR retention sweep destroys them (Track I-a). Integer 1–99; default 6 ≈ UK simple-contract limitation period.'), + reviewUrl: z.string().url().max(500).nullish().describe('Track J (#122) — company review link (Google/Yelp/Facebook). null/empty clears it.'), }).openapi('TenantConfigPatch'); const TenantConfigPatchResponseSchema = z.object({ @@ -2335,6 +2337,7 @@ export const adminRoutes = createApiRouter() blockUnsignedAgreement: config?.blockUnsignedAgreement ?? false, allowInspectorChoice: config?.allowInspectorChoice ?? false, agreementRetentionYears: config?.agreementRetentionYears ?? 6, + reviewUrl: config?.reviewUrl ?? null, }, }, 200); }) @@ -2355,6 +2358,9 @@ export const adminRoutes = createApiRouter() if (body.agreementRetentionYears !== undefined) { update.agreementRetentionYears = body.agreementRetentionYears; } + if (body.reviewUrl !== undefined) { + update.reviewUrl = body.reviewUrl || null; + } if (Object.keys(update).length === 0) { return c.json({ success: true as const, data: { ok: true as const } }, 200); } diff --git a/server/lib/validations/automation.schema.ts b/server/lib/validations/automation.schema.ts index e17226cf..bc984e6c 100644 --- a/server/lib/validations/automation.schema.ts +++ b/server/lib/validations/automation.schema.ts @@ -7,10 +7,20 @@ const AUTOMATION_TRIGGERS = [ 'agreement.signer_signed', 'agreement.viewed', 'agreement.declined', 'agreement.expired', 'event.created', 'event.completed', + 'inspection.reminder', ] as const; const AUTOMATION_RECIPIENTS = ['client', 'buying_agent', 'selling_agent', 'inspector', 'all'] as const; +const AUTOMATION_CHANNELS = ['email', 'sms'] as const; + +// Track J (D2) — send-time gates. All optional; absent = no gate. +export const ConditionsSchema = z.object({ + requirePaid: z.boolean().optional().describe('Only send if the inspection payment_status is paid.'), + requireSigned: z.boolean().optional().describe('Only send if the inspection has a signed agreement.'), + serviceIds: z.array(z.string()).optional().describe('Only send if the inspection booked one of these services; empty/absent = any.'), +}).strict(); + export const AutomationSchema = z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration'), tenantId: z.string().describe('TODO describe tenantId field for the OpenInspection MCP integration'), @@ -20,6 +30,8 @@ export const AutomationSchema = z.object({ delayMinutes: z.number().int().describe('TODO describe delayMinutes field for the OpenInspection MCP integration'), 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.'), 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'), @@ -32,6 +44,8 @@ export const CreateAutomationSchema = z.object({ delayMinutes: z.number().int().min(0).default(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.'), + channel: z.enum(AUTOMATION_CHANNELS).default('email').describe('Delivery channel.'), }).openapi('CreateAutomation'); export const UpdateAutomationSchema = CreateAutomationSchema.partial().extend({ diff --git a/tests/unit/automation-schema.spec.ts b/tests/unit/automation-schema.spec.ts new file mode 100644 index 00000000..9943a031 --- /dev/null +++ b/tests/unit/automation-schema.spec.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { CreateAutomationSchema } from '../../server/lib/validations/automation.schema'; + +describe('CreateAutomationSchema (Track J)', () => { + const base = { + name: 'R', trigger: 'report.published', recipient: 'client', + subjectTemplate: 's', bodyTemplate: 'b', + }; + + it('accepts the new inspection.reminder trigger', () => { + const r = CreateAutomationSchema.safeParse({ ...base, trigger: 'inspection.reminder' }); + expect(r.success).toBe(true); + }); + + it('defaults channel to email and accepts sms', () => { + 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); + }); + + it('accepts a conditions object and rejects a malformed one', () => { + const ok = CreateAutomationSchema.safeParse({ + ...base, conditions: { requirePaid: true, serviceIds: ['s1'] }, + }); + expect(ok.success).toBe(true); + const bad = CreateAutomationSchema.safeParse({ ...base, conditions: { serviceIds: 'nope' } }); + expect(bad.success).toBe(false); + }); +}); From 698789f55e9d86d83b45941bb8efac587b5885bf Mon Sep 17 00:00:00 2001 From: important-new Date: Mon, 8 Jun 2026 15:43:53 +0800 Subject: [PATCH 08/13] feat(automations): persist conditions (JSON) + channel through create/update Co-Authored-By: Claude Opus 4.8 --- server/services/automation.service.ts | 25 +++++++++--- tests/unit/automation-conditions.spec.ts | 48 ++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 tests/unit/automation-conditions.spec.ts diff --git a/server/services/automation.service.ts b/server/services/automation.service.ts index eda18e54..341b8ecf 100644 --- a/server/services/automation.service.ts +++ b/server/services/automation.service.ts @@ -67,17 +67,22 @@ export class AutomationService { async create(tenantId: string, data: { name: string; trigger: string; recipient: string; delayMinutes: number; subjectTemplate: string; bodyTemplate: string; + conditions?: { requirePaid?: boolean; requireSigned?: boolean; serviceIds?: string[] } | null; + channel?: 'email' | 'sms'; }) { const db = this.getDrizzle(); const id = nanoid(); + const { conditions, channel, ...rest } = data; await db.insert(automations).values({ - id, tenantId, ...data, + id, tenantId, ...rest, // Casts narrow the public string param to the schema's enum literal // union; runtime values are validated by the API zod schema. // eslint-disable-next-line @typescript-eslint/no-explicit-any - trigger: data.trigger as any, + trigger: rest.trigger as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any - recipient: data.recipient as any, + recipient: rest.recipient as any, + conditions: conditions ? JSON.stringify(conditions) : null, + channel: channel ?? 'email', active: true, isDefault: false, createdAt: new Date(), }); return (await db.select().from(automations).where(eq(automations.id, id)))[0]; @@ -86,14 +91,22 @@ export class AutomationService { async update(tenantId: string, id: string, data: Partial<{ 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'; }>) { 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'); - // Same enum-narrowing as create() — public Partial<{string}> → table's enum literals. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await db.update(automations).set(data as any) + const { conditions, ...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; + // 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))); return (await db.select().from(automations).where(eq(automations.id, id)))[0]; } diff --git a/tests/unit/automation-conditions.spec.ts b/tests/unit/automation-conditions.spec.ts new file mode 100644 index 00000000..c3085917 --- /dev/null +++ b/tests/unit/automation-conditions.spec.ts @@ -0,0 +1,48 @@ +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 create/update — conditions + channel (Track J)', () => { + it('serializes conditions to JSON and defaults channel to email', 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.conditions!)).toEqual({ requirePaid: true, serviceIds: ['svc-1'] }); + }); + + it('update can clear conditions and set channel', 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' }); + expect(updated.conditions).toBeNull(); + expect(updated.channel).toBe('sms'); + }); +}); From 3179de7b2c17130165b29eec6c6c9ea7c485b781 Mon Sep 17 00:00:00 2001 From: important-new Date: Mon, 8 Jun 2026 15:59:41 +0800 Subject: [PATCH 09/13] feat(automations): evaluate conditions + review_url + sms at send time, skip with reason Co-Authored-By: Claude Opus 4.8 --- server/services/automation.service.ts | 72 +++++++++++++++- tests/unit/automation-conditions.spec.ts | 101 +++++++++++++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/server/services/automation.service.ts b/server/services/automation.service.ts index 341b8ecf..17b5e8c3 100644 --- a/server/services/automation.service.ts +++ b/server/services/automation.service.ts @@ -1,6 +1,7 @@ import { drizzle } from 'drizzle-orm/d1'; +import type { DrizzleD1Database } from 'drizzle-orm/d1'; import { eq, and, lte, sql, desc } from 'drizzle-orm'; -import { automations, automationLogs, inspections, tenants } from '../lib/db/schema'; +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'; import { nanoid } from 'nanoid'; @@ -214,6 +215,55 @@ export class AutomationService { return null; // buying_agent/selling_agent/inspector resolved at delivery } + /** + * 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). + */ + 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' }; + if (!automation.conditions) return { ok: true }; + + let cond: { requirePaid?: boolean; requireSigned?: boolean; serviceIds?: string[] }; + try { + cond = JSON.parse(automation.conditions); + } catch { + // Malformed JSON → fail OPEN (treat as no gates) so a corrupt blob + // doesn't trap the log in 'pending' forever. Warn so the ungated send + // is observable (conditions are app-serialized, so this implies a bug + // or manual DB edit). Never log the blob contents. + logger.warn('AutomationService.evaluateConditions: malformed conditions JSON, sending ungated', + { automationId: automation.id }); + return { ok: true }; + } + + if (cond.requirePaid && inspection.paymentStatus !== 'paid') { + return { ok: false, reason: 'condition: not paid' }; + } + if (cond.requireSigned) { + const signed = await db.select({ id: agreementRequests.id }).from(agreementRequests) + .where(and( + eq(agreementRequests.tenantId, inspection.tenantId), + eq(agreementRequests.inspectionId, inspection.id), + eq(agreementRequests.status, 'signed'), + )).limit(1); + if (signed.length === 0) return { ok: false, reason: 'condition: agreement not signed' }; + } + if (cond.serviceIds && cond.serviceIds.length > 0) { + const rows = await db.select({ serviceId: inspectionServices.serviceId }).from(inspectionServices) + .where(eq(inspectionServices.inspectionId, inspection.id)); + const have = new Set(rows.map(r => r.serviceId)); + if (!cond.serviceIds.some(id => have.has(id))) { + return { ok: false, reason: 'condition: service not matched' }; + } + } + return { ok: true }; + } + async flush(resendApiKey: string, senderEmail: string, appName: string, appBaseUrl: string, batchSize = 50): Promise { const db = this.getDrizzle(); const now = new Date().toISOString(); @@ -237,6 +287,13 @@ export class AutomationService { for (const { log, automation, inspection, tenant } of pending) { try { + const verdict = await this.evaluateConditions(db, automation, inspection); + if (!verdict.ok) { + await db.update(automationLogs).set({ status: 'skipped', error: verdict.reason }) + .where(eq(automationLogs.id, log.id)); + continue; + } + const vars: Record = { client_name: inspection.clientName ?? '', property_address: inspection.propertyAddress, @@ -287,6 +344,19 @@ export class AutomationService { } } + const needsReviewUrl = automation.bodyTemplate.includes('{{review_url}}') || + automation.subjectTemplate.includes('{{review_url}}'); + if (needsReviewUrl) { + const cfg = await db.select({ reviewUrl: tenantConfigs.reviewUrl }).from(tenantConfigs) + .where(eq(tenantConfigs.tenantId, inspection.tenantId)).get(); + if (!cfg?.reviewUrl) { + await db.update(automationLogs).set({ status: 'skipped', error: 'review_url not configured' }) + .where(eq(automationLogs.id, log.id)); + continue; + } + vars.review_url = cfg.reviewUrl; + } + const subject = interpolate(automation.subjectTemplate, vars); const html = interpolate(automation.bodyTemplate, vars); const from = senderEmail || `noreply@${appName.toLowerCase().replace(/\s+/g, '')}.com`; diff --git a/tests/unit/automation-conditions.spec.ts b/tests/unit/automation-conditions.spec.ts index c3085917..7247a74a 100644 --- a/tests/unit/automation-conditions.spec.ts +++ b/tests/unit/automation-conditions.spec.ts @@ -46,3 +46,104 @@ describe('AutomationService create/update — conditions + channel (Track J)', ( expect(updated.channel).toBe('sms'); }); }); + +async function seedInspection(over: Partial = {}) { + const id = over.id ?? crypto.randomUUID(); + await db.insert(schema.inspections).values({ + id, tenantId: TENANT, propertyAddress: '1 Main', clientName: 'Jane', + clientEmail: 'jane@example.com', date: '2026-06-01', status: 'published', + paymentStatus: 'unpaid', price: 50000, agreementRequired: false, + paymentRequired: false, createdAt: new Date(), ...over, + } as never); + return id; +} + +async function seedRuleAndLog(opts: { + conditions?: object | null; channel?: 'email' | 'sms'; body?: string; inspectionId: string; +}) { + const ruleId = crypto.randomUUID(); + await db.insert(schema.automations).values({ + id: ruleId, tenantId: TENANT, name: 'R', trigger: 'report.published', + recipient: 'client', delayMinutes: 0, subjectTemplate: 'Subj', + bodyTemplate: opts.body ?? 'Body', active: true, isDefault: false, + createdAt: new Date(), + conditions: opts.conditions ? JSON.stringify(opts.conditions) : null, + channel: opts.channel ?? 'email', + } as never); + 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(), + status: 'pending', + } as never); + return logId; +} + +async function statusOf(logId: string) { + const r = await db.select().from(schema.automationLogs) + .where(eq(schema.automationLogs.id, logId)).get(); + return { status: r?.status, error: r?.error }; +} + +describe('AutomationService.flush — send-time gates (Track J)', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response('{"id":"re_1"}', { status: 200 }))); + }); + + it('requirePaid skips an unpaid inspection', async () => { + const insp = await seedInspection({ paymentStatus: 'unpaid' }); + const logId = await seedRuleAndLog({ conditions: { requirePaid: true }, 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(/not paid/); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('requirePaid sends when paid', async () => { + const insp = await seedInspection({ paymentStatus: 'paid' }); + const logId = await seedRuleAndLog({ conditions: { requirePaid: true }, inspectionId: insp }); + await svc.flush('rk', 'from@x.com', 'Acme', 'https://acme.example.com'); + expect((await statusOf(logId)).status).toBe('sent'); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('requireSigned skips when no signed agreement_request exists', async () => { + const insp = await seedInspection(); + const logId = await seedRuleAndLog({ conditions: { requireSigned: true }, 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(/not signed/); + }); + + it('serviceIds skips when the inspection booked none of them', async () => { + const insp = await seedInspection(); + const logId = await seedRuleAndLog({ conditions: { serviceIds: ['svc-x'] }, inspectionId: insp }); + await svc.flush('rk', 'from@x.com', 'Acme', 'https://acme.example.com'); + 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 }); + await svc.flush('rk', 'from@x.com', 'Acme', 'https://acme.example.com'); + expect((await statusOf(logId)).status).toBe('skipped'); + + await db.insert(schema.tenantConfigs).values({ tenantId: TENANT, reviewUrl: 'https://g.page/r/acme', updatedAt: new Date() } as never); + const logId2 = await seedRuleAndLog({ body: 'Leave us a review: {{review_url}}', inspectionId: insp }); + await svc.flush('rk', 'from@x.com', 'Acme', 'https://acme.example.com'); + expect((await statusOf(logId2)).status).toBe('sent'); + }); +}); From 880e18dca7cbce3397fbfc363d016d35e3c3af0c Mon Sep 17 00:00:00 2001 From: important-new Date: Mon, 8 Jun 2026 16:15:06 +0800 Subject: [PATCH 10/13] =?UTF-8?q?feat(automations):=20inspection.reminder?= =?UTF-8?q?=20cron=20=E2=80=94=20enqueueReminders=20+=20scheduled=20step?= =?UTF-8?q?=20(D7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- server/scheduled.ts | 10 +++ server/services/automation.service.ts | 78 ++++++++++++++++- tests/unit/automation-reminders.spec.ts | 109 ++++++++++++++++++++++++ 3 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 tests/unit/automation-reminders.spec.ts diff --git a/server/scheduled.ts b/server/scheduled.ts index ec184e6b..9b5e6ca0 100644 --- a/server/scheduled.ts +++ b/server/scheduled.ts @@ -109,6 +109,16 @@ export async function scheduled( logger.error('[cron] QBO CDC failed', {}, e instanceof Error ? e : undefined); } + // 3a. Track J — enqueue inspection.reminder logs (no email key needed to enqueue; + // the flush below sends due ones). Idempotent per (rule, inspection). + try { + const svc = new AutomationService(env.DB); + const n = await svc.enqueueReminders(Date.now()); + if (n > 0) logger.info('[cron] enqueued inspection reminders', { count: n }); + } catch (e) { + 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'); diff --git a/server/services/automation.service.ts b/server/services/automation.service.ts index 17b5e8c3..3b90697c 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, sql, desc } from 'drizzle-orm'; +import { eq, and, lte, gte, sql, desc, notInArray } 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'; @@ -226,6 +226,20 @@ export class AutomationService { 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 + // cancelled inspection). NOTE: a reschedule to a DIFFERENT date is a known + // v1 limitation — the reminder still fires at the originally-computed time, + // because we don't currently mutate reminder logs when an inspection's date + // changes. event_id has no unique index on purpose: Spec 4D reminder+follow-up + // logs intentionally share an inspection-event id, so the cron's + // check-then-insert dedup (safe because CF cron runs are effectively serial) + // is the chosen guard rather than a DB unique constraint. + if (automation.trigger === 'inspection.reminder' && + ['cancelled', 'completed', 'delivered', 'published'].includes(inspection.status)) { + return { ok: false, reason: 'inspection no longer active' }; + } if (!automation.conditions) return { ok: true }; let cond: { requirePaid?: boolean; requireSigned?: boolean; serviceIds?: string[] }; @@ -310,7 +324,10 @@ export class AutomationService { }; // Spec 4D — populate event vars when log was created by EventService. - if (log.eventId) { + // Spec 4D event-vars apply only to logs linked to a real inspection + // event. Track J reminders reuse event_id as a "reminder::" + // dedup key that never matches an inspectionEvents row, so skip the lookup. + if (log.eventId && !log.eventId.startsWith('reminder:')) { try { const { eventTypes, inspectionEvents } = await import('../lib/db/schema'); const ev = await db.select().from(inspectionEvents).where(eq(inspectionEvents.id, log.eventId)).get(); @@ -386,6 +403,63 @@ export class AutomationService { } } + /** + * Track J (D7) — appointment reminders. Cron-fired daily. For each active + * inspection.reminder rule, scan upcoming inspections within the rule's lead + * window and enqueue a pending automation_log at (inspection date − lead), + * floored to now+5min. Deduped on eventId = reminder:: + * so re-scans don't double-create. The existing flush() sends it when due and + * re-checks conditions per D4. Reminders are day-granular (inspections.date is + * date-only); we anchor the appointment at 09:00 UTC. + */ + async enqueueReminders(nowMs: number): Promise { + const db = this.getDrizzle(); + const rules = await db.select().from(automations) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .where(and(eq(automations.trigger, 'inspection.reminder' as any), eq(automations.active, true))); + if (rules.length === 0) return 0; + + const todayStr = new Date(nowMs).toISOString().slice(0, 10); + let created = 0; + + for (const rule of rules) { + // Window upper bound = lead + 1.5d buffer so a same-day cron still + // catches an appointment whose lead window opens within the next day. + const upperStr = new Date(nowMs + rule.delayMinutes * 60_000 + 36 * 3600_000) + .toISOString().slice(0, 10); + const upcoming = await db.select().from(inspections) + .where(and( + eq(inspections.tenantId, rule.tenantId), + gte(inspections.date, todayStr), + lte(inspections.date, upperStr), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + notInArray(inspections.status, ['cancelled', 'completed', 'delivered', 'published'] as any), + )); + + 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, recipientEmail: insp.clientEmail, + sendAt: new Date(sendAt).toISOString(), status: 'pending', eventId, + }); + created++; + } + } + return created; + } + async getLogs(tenantId: string, inspectionId: string) { const db = this.getDrizzle(); return db.select().from(automationLogs) diff --git a/tests/unit/automation-reminders.spec.ts b/tests/unit/automation-reminders.spec.ts new file mode 100644 index 00000000..1ad09777 --- /dev/null +++ b/tests/unit/automation-reminders.spec.ts @@ -0,0 +1,109 @@ +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; +const NOW = Date.parse('2026-05-30T08:00:00Z'); // fixed clock for the test + +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); +}); + +async function reminderRule(delayMinutes = 1440) { + const id = crypto.randomUUID(); + await db.insert(schema.automations).values({ + id, tenantId: TENANT, name: 'Appt reminder', trigger: 'inspection.reminder', + recipient: 'client', delayMinutes, subjectTemplate: 'Reminder', bodyTemplate: 'See you {{scheduled_date}}', + active: true, isDefault: false, createdAt: new Date(), + } as never); + return id; +} +async function insp(date: string, over: Partial = {}) { + const id = crypto.randomUUID(); + await db.insert(schema.inspections).values({ + id, tenantId: TENANT, propertyAddress: '1 Main', clientName: 'Jane', + clientEmail: 'jane@example.com', date, status: 'scheduled', paymentStatus: 'unpaid', + price: 0, agreementRequired: false, paymentRequired: false, createdAt: new Date(), ...over, + } as never); + return id; +} +async function logsFor(inspectionId: string) { + return db.select().from(schema.automationLogs) + .where(eq(schema.automationLogs.inspectionId, inspectionId)); +} + +describe('AutomationService.enqueueReminders (Track J D7)', () => { + it('creates one pending log at date − lead for an upcoming inspection', async () => { + const ruleId = await reminderRule(1440); // 24h lead + const i = await insp('2026-05-31'); // tomorrow at 09:00Z → lead 24h → 2026-05-30T09:00Z + const n = await svc.enqueueReminders(NOW); + expect(n).toBe(1); + const [log] = await logsFor(i); + expect(log.status).toBe('pending'); + expect(log.automationId).toBe(ruleId); + expect(log.eventId).toBe(`reminder:${ruleId}:${i}`); + expect(Date.parse(log.sendAt)).toBe(Date.parse('2026-05-30T09:00:00Z')); + }); + + it('floors a near-term reminder to now + 5min', async () => { + await reminderRule(1440); + const i = await insp('2026-05-30'); // today 09:00Z; 24h lead is already in the past + await svc.enqueueReminders(NOW); + const [log] = await logsFor(i); + expect(Date.parse(log.sendAt)).toBe(NOW + 5 * 60_000); + }); + + it('is idempotent — re-scan does not double-create', async () => { + await reminderRule(1440); + const i = await insp('2026-05-31'); + await svc.enqueueReminders(NOW); + await svc.enqueueReminders(NOW); + expect((await logsFor(i)).length).toBe(1); + }); + + it('ignores cancelled/completed inspections and inspections with no client email', async () => { + await reminderRule(1440); + await insp('2026-05-31', { status: 'cancelled' }); + await insp('2026-05-31', { clientEmail: null }); + const n = await svc.enqueueReminders(NOW); + expect(n).toBe(0); + }); + + it('creates nothing when there is no active inspection.reminder rule', async () => { + await insp('2026-05-31'); + expect(await svc.enqueueReminders(NOW)).toBe(0); + }); + + it('suppresses a reminder whose inspection was cancelled after enqueue', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{"id":"re_1"}', { status: 200 }))); + await reminderRule(1440); + const i = await insp('2026-05-31'); + await svc.enqueueReminders(NOW); + // inspection gets cancelled before the reminder is due + await db.update(schema.inspections).set({ status: 'cancelled' }).where(eq(schema.inspections.id, i)); + // force the pending log due now + await db.update(schema.automationLogs).set({ sendAt: new Date(NOW - 1000).toISOString() }) + .where(eq(schema.automationLogs.inspectionId, i)); + await svc.flush('rk', 'from@x.com', 'Acme', 'https://acme.example.com'); + const [log] = await logsFor(i); + expect(log.status).toBe('skipped'); + expect(log.error).toMatch(/no longer active/); + expect(fetch).not.toHaveBeenCalled(); + }); +}); From 6436ba22363509e9b3d88d8a4a6ddd264367cd92 Mon Sep 17 00:00:00 2001 From: important-new Date: Mon, 8 Jun 2026 16:31:15 +0800 Subject: [PATCH 11/13] feat(automations): #122 follow-up + review-request seeds (review inactive, fail-closed) + defaultActive Co-Authored-By: Claude Opus 4.8 --- server/data/automation-seeds.ts | 23 +++++++++++++ server/lib/integration/standalone.ts | 35 ++++++++++++-------- server/services/automation.service.ts | 2 +- tests/unit/automation-seeds.spec.ts | 47 +++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 15 deletions(-) create mode 100644 tests/unit/automation-seeds.spec.ts diff --git a/server/data/automation-seeds.ts b/server/data/automation-seeds.ts index 3b007076..28e87971 100644 --- a/server/data/automation-seeds.ts +++ b/server/data/automation-seeds.ts @@ -144,4 +144,27 @@ export const AUTOMATION_SEEDS = [ bodyTemplate: '

Hi {{client_name}},

The results for your {{event_type_name}} at {{property_address}} are now available in your inspection report.

— {{company_name}}

', isDefault: true, }, + // Track J (#122) — post-delivery follow-up. One day after the report is + // published, thank the client and invite questions. + { + name: 'Post-inspection follow-up', + trigger: 'report.published' as const, + recipient: 'client' as const, + delayMinutes: 1440, // 1 day + subjectTemplate: 'Following up on your inspection — {{property_address}}', + bodyTemplate: '

Hi {{client_name}},

We hope your inspection report for {{property_address}} was helpful. If anything in it raised a question, just reply to this email — we are happy to walk you through it.

— {{company_name}}

', + isDefault: true, + }, + // Track J (#122) — review request. Three days after publish. Seeded INACTIVE + // and engine-skips until tenant_configs.review_url is set (fail-closed). + { + name: 'Review request', + trigger: 'report.published' as const, + recipient: 'client' as const, + delayMinutes: 4320, // 3 days + subjectTemplate: 'How did we do? — {{property_address}}', + bodyTemplate: '

Hi {{client_name}},

Thanks again for choosing us for your inspection at {{property_address}}. If you have a moment, a short review really helps other homebuyers find us:

Leave a review

— {{company_name}}

', + isDefault: true, + defaultActive: false, + }, ] as const; diff --git a/server/lib/integration/standalone.ts b/server/lib/integration/standalone.ts index 1f36cafc..10066633 100644 --- a/server/lib/integration/standalone.ts +++ b/server/lib/integration/standalone.ts @@ -65,29 +65,36 @@ async function seedDefaultComments(db: D1Database, tenantId: string): Promise { - const rows: Array<[string, string, string, string, string]> = [ - ['report.published', 'client', 'Report Ready (Client)', 'Your inspection report is ready — {{property_address}}', '

Hi {{client_name}},

Your inspection report for {{property_address}} is ready to view.

View Report

— {{company_name}}

'], - ['report.published', 'buying_agent', "Report Ready (Buyer's Agent)", 'Your inspection report is ready — {{property_address}}', '

The inspection report for {{property_address}} is ready.

View Report

— {{company_name}}

'], - ['inspection.confirmed', 'client', '24-Hour Reminder', 'Reminder: Inspection tomorrow — {{property_address}}', '

Hi {{client_name}},

Just a reminder that your inspection at {{property_address}} is scheduled for {{scheduled_date}}. Your inspector will arrive during the scheduled window.

— {{company_name}}

'], - ['inspection.cancelled', 'client', 'Cancellation Notice (Client)', 'Inspection cancelled — {{property_address}}', '

Hi {{client_name}},

Your inspection at {{property_address}} has been cancelled. Please contact us to reschedule.

— {{company_name}}

'], - ['inspection.cancelled', 'buying_agent', "Cancellation Notice (Buyer's Agent)", 'Inspection cancelled — {{property_address}}', '

The inspection at {{property_address}} has been cancelled. The client may need to reschedule.

— {{company_name}}

'], - ['inspection.created', 'client', 'Send Agreement to Client', 'Please sign your inspection agreement — {{property_address}}', '

Hi {{client_name}},

Please review and sign the inspection agreement for {{property_address}} scheduled for {{scheduled_date}}.

Review & Sign Agreement

— {{company_name}}

'], - ['agreement.signed', 'client', 'Agreement Signed Confirmation', 'Confirmation: agreement signed — {{property_address}}', '

Hi {{client_name}},

Thank you for signing the inspection agreement for {{property_address}}. We will see you on {{scheduled_date}}.

— {{company_name}}

'], - ['invoice.created', 'client', 'Invoice / Payment Request', 'Invoice for your inspection — {{property_address}}', '

Hi {{client_name}},

An invoice has been created for your inspection at {{property_address}}.

View & Pay Invoice

— {{company_name}}

'], - ['payment.received', 'inspector', 'Payment Received (Inspector)', 'Payment received — {{property_address}}', '

Payment has been received for the inspection at {{property_address}} (client: {{client_name}}).

— {{company_name}}

'], - ['payment.received', 'client', 'Payment Received (Client Receipt)', 'Receipt: payment received — {{property_address}}', '

Hi {{client_name}},

Thank you — your payment for the inspection at {{property_address}} has been received.

— {{company_name}}

'], + // Tuple shape: [trigger, recipient, name, subject, body, active]. The trailing + // active flag is 1 (enabled) for all lifecycle rules; only the Track J (#122) + // "Review request" row is 0 (seeded inactive — fail-closed until review_url set). + // NOTE: keep these rows semantically in sync with AUTOMATION_SEEDS in + // server/data/automation-seeds.ts (the parallel seed path used by ensureSeeds). + const rows: Array<[string, string, string, string, string, number]> = [ + ['report.published', 'client', 'Report Ready (Client)', 'Your inspection report is ready — {{property_address}}', '

Hi {{client_name}},

Your inspection report for {{property_address}} is ready to view.

View Report

— {{company_name}}

', 1], + ['report.published', 'buying_agent', "Report Ready (Buyer's Agent)", 'Your inspection report is ready — {{property_address}}', '

The inspection report for {{property_address}} is ready.

View Report

— {{company_name}}

', 1], + ['inspection.confirmed', 'client', '24-Hour Reminder', 'Reminder: Inspection tomorrow — {{property_address}}', '

Hi {{client_name}},

Just a reminder that your inspection at {{property_address}} is scheduled for {{scheduled_date}}. Your inspector will arrive during the scheduled window.

— {{company_name}}

', 1], + ['inspection.cancelled', 'client', 'Cancellation Notice (Client)', 'Inspection cancelled — {{property_address}}', '

Hi {{client_name}},

Your inspection at {{property_address}} has been cancelled. Please contact us to reschedule.

— {{company_name}}

', 1], + ['inspection.cancelled', 'buying_agent', "Cancellation Notice (Buyer's Agent)", 'Inspection cancelled — {{property_address}}', '

The inspection at {{property_address}} has been cancelled. The client may need to reschedule.

— {{company_name}}

', 1], + ['inspection.created', 'client', 'Send Agreement to Client', 'Please sign your inspection agreement — {{property_address}}', '

Hi {{client_name}},

Please review and sign the inspection agreement for {{property_address}} scheduled for {{scheduled_date}}.

Review & Sign Agreement

— {{company_name}}

', 1], + ['agreement.signed', 'client', 'Agreement Signed Confirmation', 'Confirmation: agreement signed — {{property_address}}', '

Hi {{client_name}},

Thank you for signing the inspection agreement for {{property_address}}. We will see you on {{scheduled_date}}.

— {{company_name}}

', 1], + ['invoice.created', 'client', 'Invoice / Payment Request', 'Invoice for your inspection — {{property_address}}', '

Hi {{client_name}},

An invoice has been created for your inspection at {{property_address}}.

View & Pay Invoice

— {{company_name}}

', 1], + ['payment.received', 'inspector', 'Payment Received (Inspector)', 'Payment received — {{property_address}}', '

Payment has been received for the inspection at {{property_address}} (client: {{client_name}}).

— {{company_name}}

', 1], + ['payment.received', 'client', 'Payment Received (Client Receipt)', 'Receipt: payment received — {{property_address}}', '

Hi {{client_name}},

Thank you — your payment for the inspection at {{property_address}} has been received.

— {{company_name}}

', 1], + ['report.published', 'client', 'Post-inspection follow-up', 'Following up on your inspection — {{property_address}}', '

Hi {{client_name}},

We hope your inspection report for {{property_address}} was helpful. If anything raised a question, just reply — we are happy to help.

— {{company_name}}

', 1], + ['report.published', 'client', 'Review request', 'How did we do? — {{property_address}}', '

Hi {{client_name}},

Thanks for choosing us for your inspection at {{property_address}}. A short review helps other homebuyers find us:

Leave a review

— {{company_name}}

', 0], // active=0: inactive until review_url configured ]; const stmt = ` INSERT INTO automations (id, tenant_id, trigger, recipient, name, delay_minutes, subject_template, body_template, active, is_default, created_at) - SELECT ${SQL_UUID_V4}, ?, ?, ?, ?, 0, ?, ?, 1, 1, unixepoch('now') + SELECT ${SQL_UUID_V4}, ?, ?, ?, ?, 0, ?, ?, ?, 1, unixepoch('now') WHERE NOT EXISTS ( SELECT 1 FROM automations WHERE tenant_id = ? AND trigger = ? AND recipient = ? AND name = ? ) `; - for (const [trigger, recipient, name, subject, body] of rows) { + for (const [trigger, recipient, name, subject, body, active] of rows) { try { await db.prepare(stmt) - .bind(tenantId, trigger, recipient, name, subject, body, tenantId, trigger, recipient, name) + .bind(tenantId, trigger, recipient, name, subject, body, active, tenantId, trigger, recipient, name) .run(); } catch (err) { logger.warn('seedDefaultAutomations.row.failed', { diff --git a/server/services/automation.service.ts b/server/services/automation.service.ts index 3b90697c..7b95a932 100644 --- a/server/services/automation.service.ts +++ b/server/services/automation.service.ts @@ -50,7 +50,7 @@ export class AutomationService { delayMinutes: seed.delayMinutes, subjectTemplate: seed.subjectTemplate, bodyTemplate: seed.bodyTemplate, - active: true, + active: (seed as { defaultActive?: boolean }).defaultActive ?? true, isDefault: true, createdAt: new Date(), })); diff --git a/tests/unit/automation-seeds.spec.ts b/tests/unit/automation-seeds.spec.ts new file mode 100644 index 00000000..218cf8f7 --- /dev/null +++ b/tests/unit/automation-seeds.spec.ts @@ -0,0 +1,47 @@ +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, and } 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('Track J seeds (#122)', () => { + it('seeds the follow-up (active) and review-request (inactive) rules', async () => { + await svc.ensureSeeds(TENANT); + const all = await db.select().from(schema.automations).where(eq(schema.automations.tenantId, TENANT)); + const followup = all.find(a => a.name === 'Post-inspection follow-up'); + const review = all.find(a => a.name === 'Review request'); + expect(followup?.active).toBe(true); + expect(followup?.delayMinutes).toBe(1440); + expect(review?.active).toBe(false); // fail-closed until review_url set + expect(review?.delayMinutes).toBe(4320); // 3 days + expect(review?.bodyTemplate).toContain('{{review_url}}'); + }); + + it('is idempotent — running twice does not duplicate', async () => { + await svc.ensureSeeds(TENANT); + await svc.ensureSeeds(TENANT); + const review = await db.select().from(schema.automations) + .where(and(eq(schema.automations.tenantId, TENANT), eq(schema.automations.name, 'Review request'))); + expect(review.length).toBe(1); + }); +}); From 073b4bc54a63e8cc4a4314d269395c1f8bf6c00b Mon Sep 17 00:00:00 2001 From: important-new Date: Mon, 8 Jun 2026 16:50:32 +0800 Subject: [PATCH 12/13] =?UTF-8?q?feat(automations):=20#121=20editor=20UI?= =?UTF-8?q?=20=E2=80=94=20When/Only-if/Do-this=20form,=20run=20log,=20revi?= =?UTF-8?q?ew-url=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- app/routes/settings-automations.tsx | 302 +++++++++++++++----- tests/web/unit/settings-automations.spec.ts | 202 +++++++++++++ 2 files changed, 428 insertions(+), 76 deletions(-) create mode 100644 tests/web/unit/settings-automations.spec.ts diff --git a/app/routes/settings-automations.tsx b/app/routes/settings-automations.tsx index 8722c707..9d0d5fc8 100644 --- a/app/routes/settings-automations.tsx +++ b/app/routes/settings-automations.tsx @@ -1,4 +1,5 @@ -import { Link, useLoaderData, Form } from "react-router"; +import { useState, useEffect } from "react"; +import { Link, useLoaderData, Form, useNavigation, useFetcher } from "react-router"; import type { Route } from "./+types/settings-automations"; import { requireToken } from "~/lib/session.server"; import { createApi } from "~/lib/api-client.server"; @@ -7,69 +8,116 @@ export function meta() { return [{ title: "Automations - Settings - OpenInspection" }]; } -interface AutomationRule { - id: string; - name: string; - trigger: string; - action: string; - active: boolean; - isDefault: boolean; +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; } +interface Svc { id: string; name: string; } +interface LogRow { id: string; recipientEmail: string; sendAt: string; status: string; error: string | null; } -const TRIGGER_LABELS: Record = { - inspection_confirmed: "Inspection confirmed", - inspection_completed: "Inspection completed", - report_delivered: "Report delivered", - payment_received: "Payment received", - booking_created: "New booking created", - reminder_24h: "24 hours before inspection", +export const TRIGGER_LABELS: Record = { + "inspection.created": "Inspection created", + "inspection.confirmed": "Inspection confirmed", + "inspection.cancelled": "Inspection cancelled", + "inspection.reminder": "Before the inspection (reminder)", + "report.published": "Report published", + "invoice.created": "Invoice created", + "payment.received": "Payment received", + "agreement.signed": "Agreement signed", + "agreement.signer_signed": "A signer signed", + "agreement.viewed": "Agreement viewed", + "agreement.declined": "Agreement declined", + "agreement.expired": "Agreement expired", + "event.created": "Event created", + "event.completed": "Event completed", }; +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"]; -const ACTION_LABELS: Record = { - send_confirmation: "Send confirmation email", - send_reminder: "Send reminder email", - send_report: "Deliver report", - send_receipt: "Send payment receipt", - send_review_request: "Request review", - notify_agent: "Notify agent", -}; +interface Conditions { requirePaid?: boolean; requireSigned?: boolean; serviceIds?: string[]; } + +/** Assemble the Only-if gate object from the editor inputs, or null when empty. */ +export function buildConditions(input: { requirePaid: boolean; requireSigned: boolean; serviceIds: string[] }): Conditions | null { + const conditions: Conditions = { + ...(input.requirePaid ? { requirePaid: true } : {}), + ...(input.requireSigned ? { requireSigned: true } : {}), + ...(input.serviceIds.length ? { serviceIds: input.serviceIds } : {}), + }; + return Object.keys(conditions).length ? conditions : null; +} export async function loader({ request, context }: Route.LoaderArgs) { const token = await requireToken(context, request); - try { - const api = createApi(context, { token }); - const res = await api.automations.index.$get(); - const body = res.ok ? ((await res.json()) as Record) : { data: [] }; - return { rules: (body.data ?? []) as AutomationRule[] }; - } catch { - return { rules: [] as AutomationRule[] }; - } + const api = createApi(context, { token }); + const [rulesRes, svcRes, logsRes, cfgRes] = await Promise.all([ + api.automations.index.$get().catch(() => null), + api.services.index.$get({}).catch(() => null), + api.automations.logs.recent.$get({ query: { limit: 50 } }).catch(() => null), + api.admin["tenant-config"].$get().catch(() => null), + ]); + const rules = (rulesRes && rulesRes.ok ? ((await rulesRes.json()) as { data?: Rule[] }).data : []) ?? []; + const services = (svcRes && svcRes.ok ? ((await svcRes.json()) as { data?: Svc[] }).data : []) ?? []; + const recentLogs = (logsRes && logsRes.ok ? ((await logsRes.json()) as { data?: LogRow[] }).data : []) ?? []; + const reviewUrl = (cfgRes && cfgRes.ok ? (((await cfgRes.json()) as { data?: { reviewUrl?: string | null } }).data?.reviewUrl) : "") ?? ""; + return { rules, services, recentLogs, reviewUrl }; } export async function action({ request, context }: Route.ActionArgs) { const token = await requireToken(context, request); + const api = createApi(context, { token }); const form = await request.formData(); const intent = form.get("intent"); if (intent === "toggle") { const id = String(form.get("id") ?? ""); const active = form.get("active") === "true"; - const api = createApi(context, { token }); - await api.automations[":id"].$patch({ - param: { id }, - json: { active: !active }, + const res = await api.automations[":id"].$patch({ param: { id }, json: { active: !active } }); + return { ok: res.ok, error: res.ok ? undefined : "Request failed" }; + } + if (intent === "delete") { + const id = String(form.get("id") ?? ""); + const res = await api.automations[":id"].$delete({ param: { id } }); + return { ok: res.ok, error: res.ok ? undefined : "Request failed" }; + } + if (intent === "save-review-url") { + const reviewUrl = String(form.get("reviewUrl") ?? "").trim(); + const res = await api.admin["tenant-config"].$patch({ json: { reviewUrl: reviewUrl || null } }); + return { ok: res.ok, error: res.ok ? undefined : "Request failed" }; + } + if (intent === "save") { + const serviceIds = form.getAll("serviceIds").map(String).filter(Boolean); + const conditions = buildConditions({ + requirePaid: form.get("requirePaid") === "on", + requireSigned: form.get("requireSigned") === "on", + serviceIds, }); + const json = { + name: String(form.get("name") ?? ""), + trigger: String(form.get("trigger") ?? ""), + recipient: String(form.get("recipient") ?? "client"), + delayMinutes: Number(form.get("delayMinutes") ?? 0), + subjectTemplate: String(form.get("subjectTemplate") ?? ""), + bodyTemplate: String(form.get("bodyTemplate") ?? ""), + channel: "email", + conditions, + }; + const id = String(form.get("id") ?? ""); + const res = id + ? await (api.automations[":id"].$patch as unknown as (a: { param: { id: string }; json: typeof json }) => Promise)({ param: { id }, json }) + : await (api.automations.index.$post as unknown as (a: { json: typeof json }) => Promise)({ json }); + return { ok: res.ok, error: res.ok ? undefined : "Request failed" }; } - return { ok: true }; } export default function SettingsAutomations() { - const { rules } = useLoaderData(); + const { rules, services, recentLogs, reviewUrl } = useLoaderData(); + const nav = useNavigation(); + const [editing, setEditing] = useState(null); return (
- {/* Breadcrumb */}
Settings @@ -79,25 +127,29 @@ export default function SettingsAutomations() {

Automations

-

- Emails sent automatically when inspection events occur. -

+

Emails sent automatically when inspection events occur.

-
- {/* Rules table */} +
+ + +

Paste your Google/Yelp review link. The “Review request” automation stays off until this is set.

+
+ + +
+
+
{rules.length === 0 ? ( -
-
- -
-

No automations yet

-

Add an automation rule to send emails on inspection events.

-
+
No automations yet.
) : (
{rules.map((rule) => ( @@ -105,32 +157,19 @@ export default function SettingsAutomations() {

{rule.name}

- {rule.isDefault && ( - - Default - - )} + {rule.isDefault && Default} + {rule.channel === "sms" && SMS}
-

- {TRIGGER_LABELS[rule.trigger] || rule.trigger} - - {ACTION_LABELS[rule.action] || rule.action} -

+

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

-
+ + -
@@ -138,14 +177,125 @@ export default function SettingsAutomations() {
)}
+ +
+
Recent activity
+ {recentLogs.length === 0 ? ( +
No automation activity yet.
+ ) : ( +
+ {recentLogs.map((l) => ( +
+ {l.recipientEmail} + {new Date(l.sendAt).toLocaleString()} + {l.status} + {l.error && {l.error}} +
+ ))} +
+ )} +
+ + {editing && ( + setEditing(null)} /> + )}
); } -function BoltIcon() { +function AutomationEditor({ rule, services, onClose }: { rule: Rule | null; services: Svc[]; onClose: () => void }) { + const parsed: Conditions = rule?.conditions ? (JSON.parse(rule.conditions) as Conditions) : {}; + const fetcher = useFetcher<{ ok: boolean; error?: string }>(); + const submitting = fetcher.state !== "idle"; + const [confirmDelete, setConfirmDelete] = useState(false); + + useEffect(() => { + if (fetcher.state === "idle" && fetcher.data?.ok) onClose(); + }, [fetcher.state, fetcher.data, onClose]); + return ( - - - +
+ e.stopPropagation()} className="bg-ih-bg-card border border-ih-border rounded-xl w-full max-w-lg max-h-[90vh] overflow-auto p-5 space-y-5"> + + {rule && } +

{rule ? "Edit automation" : "New automation"}

+ + + +
+ When + +
+ +
+ Only if + + +
+

Limit to services (none = any):

+
+ {services.map((s) => ( + + ))} +
+
+
+ +
+ Do this +
+ + + +
+ +