diff --git a/app/components/editor/InspectionSettingsSheet.tsx b/app/components/editor/InspectionSettingsSheet.tsx index 9712cf8af..3f0981e44 100644 --- a/app/components/editor/InspectionSettingsSheet.tsx +++ b/app/components/editor/InspectionSettingsSheet.tsx @@ -1,6 +1,8 @@ import { useState, useEffect, useRef } from "react"; import { useFetcher } from "react-router"; import { TemplateCombobox } from "~/components/TemplateCombobox"; +import { CoverCropper } from "~/components/image-studio/CoverCropper"; +import { fullResUrl } from "~/components/image-studio/cropImage"; interface SettingsForm { date: string; @@ -80,8 +82,9 @@ export function InspectionSettingsSheet({ open, onClose, inspectionId, referralS // `coverKey` is the chosen cover R2 key (optimistic; PATCHed via coverFetcher). const [photos, setPhotos] = useState([]); const [coverKey, setCoverKey] = useState(""); - const coverFetcher = useFetcher<{ ok: boolean; intent?: string; coverKey?: string; coverUrl?: string | null }>(); + const coverFetcher = useFetcher<{ ok: boolean; intent?: string; coverKey?: string | null; coverUrl?: string | null }>(); const coverFileRef = useRef(null); + const [cropSource, setCropSource] = useState<{ key: string; url: string } | null>(null); // Trigger load when the sheet opens or inspectionId changes useEffect(() => { @@ -121,9 +124,13 @@ export function InspectionSettingsSheet({ open, onClose, inspectionId, referralS }, [loadFetcher.data]); function selectCover(key: string) { - const next = coverKey === key ? "" : key; // click current cover to clear - setCoverKey(next); - coverFetcher.submit({ intent: "set-cover", coverPhotoId: next }, { method: "post" }); + if (coverKey === key) { + setCoverKey(""); + coverFetcher.submit({ intent: "set-cover", coverPhotoId: "" }, { method: "post" }); + return; + } + const photo = photos.find((p) => p.key === key); + if (photo) setCropSource({ key, url: photo.url }); } // DB-16 — direct cover upload (Spectora parity). The file rides the BFF relay @@ -143,11 +150,14 @@ export function InspectionSettingsSheet({ open, onClose, inspectionId, referralS if (coverFetcher.state === "idle" && d?.intent === "upload-cover" && d.ok && d.coverKey) { const key = d.coverKey; const url = d.coverUrl ?? null; - setCoverKey(key); if (url) { setPhotos((prev) => (prev.some((p) => p.key === key) ? prev : [{ key, url, label: "Uploaded" }, ...prev])); + setCropSource({ key, url }); } } + if (coverFetcher.state === "idle" && d?.intent === "crop-cover" && d.ok && d.coverKey) { + setCoverKey(d.coverKey); + } }, [coverFetcher.state, coverFetcher.data]); // Sync loading state with fetcher @@ -376,6 +386,22 @@ export function InspectionSettingsSheet({ open, onClose, inspectionId, referralS )} + {cropSource && ( + setCropSource(null)} + onSave={(blob, c) => { + const fd = new FormData(); + fd.append("intent", "crop-cover"); + fd.append("sourceKey", cropSource.key); + fd.append("crop", JSON.stringify({ aspect: c.aspect, orientation: c.orientation, ...c.pixels })); + fd.append("image", new File([blob], "cover.jpg", { type: "image/jpeg" })); + coverFetcher.submit(fd, { method: "post", encType: "multipart/form-data" }); + setCropSource(null); + }} + /> + )} ); } diff --git a/app/components/editor/SideRail.tsx b/app/components/editor/SideRail.tsx index da0dfffa1..62e149c18 100644 --- a/app/components/editor/SideRail.tsx +++ b/app/components/editor/SideRail.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { renderTemplate } from "../../lib/mustache"; import { DEFECT_TRADE_LABELS, DEFECT_DEADLINE_LABELS, DEFECT_TIMEFRAME_LABELS } from "../../lib/defect-fields"; import { photoDisplayName, withDownload } from "../../lib/photo-name"; +import { PhotoGallery } from "~/components/image-studio/PhotoGallery"; interface SideRailProps { activeItem?: { id: string; label: string; type?: string } | null; @@ -10,16 +11,19 @@ interface SideRailProps { getRatingColor?: (id: string) => string; getRatingLabel?: (id: string) => string; inspectionId?: string; + onGallerySetCover?: (photo: { key: string; url: string }) => void; + onGalleryAnnotate?: (photo: { key: string; url: string }) => void; } -type TabId = "preview" | "library"; +type TabId = "preview" | "library" | "photos"; const TABS: Array<{ id: TabId; label: string; icon: string }> = [ { id: "preview", label: "Preview", icon: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" }, { id: "library", label: "Library", icon: "M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" }, + { id: "photos", label: "Photos", icon: "M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14M4 6h16a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V8a2 2 0 012-2z" }, ]; -export function SideRail({ activeItem, activeResult, getRatingColor, getRatingLabel, inspectionId }: SideRailProps) { +export function SideRail({ activeItem, activeResult, getRatingColor, getRatingLabel, inspectionId, onGallerySetCover, onGalleryAnnotate }: SideRailProps) { const [activeTab, setActiveTab] = useState("preview"); const [open, setOpen] = useState(false); @@ -145,6 +149,13 @@ export function SideRail({ activeItem, activeResult, getRatingColor, getRatingLa

Type / in the note field to search.

)} + {activeTab === "photos" && ( + inspectionId ? ( + onGallerySetCover?.(p)} onAnnotate={(p) => onGalleryAnnotate?.(p)} /> + ) : ( +

Open an inspection to browse photos.

+ ) + )} )} diff --git a/app/components/image-studio/AvatarCropper.tsx b/app/components/image-studio/AvatarCropper.tsx new file mode 100644 index 000000000..5fcf02488 --- /dev/null +++ b/app/components/image-studio/AvatarCropper.tsx @@ -0,0 +1,40 @@ +import { useState, useCallback } from "react"; +import Cropper from "react-easy-crop"; +import { bakeCrop, type PixelCrop } from "./cropImage"; + +const AVATAR_EDGE = 512; +export interface AvatarCropperProps { + sourceUrl: string; + onCancel: () => void; + onSave: (blob: Blob) => void; +} +export function AvatarCropper({ sourceUrl, onCancel, onSave }: AvatarCropperProps) { + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [pixels, setPixels] = useState(null); + const [busy, setBusy] = useState(false); + const onComplete = useCallback((_a: unknown, p: PixelCrop) => setPixels(p), []); + async function handleSave() { + if (!pixels) return; + setBusy(true); + try { onSave(await bakeCrop(sourceUrl, pixels, AVATAR_EDGE)); } finally { setBusy(false); } + } + return ( +
+
+ +
+
+
+ Zoom + setZoom(Number(e.target.value))} className="flex-1 accent-ih-primary" aria-label="Zoom" /> +
+
+ + +
+
+
+ ); +} diff --git a/app/components/image-studio/CoverCropper.tsx b/app/components/image-studio/CoverCropper.tsx new file mode 100644 index 000000000..3c6494b35 --- /dev/null +++ b/app/components/image-studio/CoverCropper.tsx @@ -0,0 +1,68 @@ +import { useState, useCallback } from "react"; +import Cropper from "react-easy-crop"; +import { bakeCrop, type PixelCrop } from "./cropImage"; + +type Aspect = "3:2" | "16:9" | "1.91:1" | "4:3"; +const RATIOS: Record = { "3:2": 3 / 2, "16:9": 16 / 9, "1.91:1": 1.91, "4:3": 4 / 3 }; +const PRESETS: Aspect[] = ["3:2", "16:9", "1.91:1", "4:3"]; + +export interface CoverCropperProps { + sourceUrl: string; + sourceKey: string; + onCancel: () => void; + onSave: (blob: Blob, crop: { aspect: Aspect; orientation: "landscape" | "portrait"; pixels: PixelCrop }) => void; +} + +export function CoverCropper({ sourceUrl, sourceKey, onCancel, onSave }: CoverCropperProps) { + void sourceKey; + const [aspect, setAspect] = useState("3:2"); + const [portrait, setPortrait] = useState(false); + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [pixels, setPixels] = useState(null); + const [busy, setBusy] = useState(false); + const ratio = portrait ? 1 / RATIOS[aspect] : RATIOS[aspect]; + const onCropComplete = useCallback((_a: unknown, areaPixels: PixelCrop) => setPixels(areaPixels), []); + + async function handleSave() { + if (!pixels) return; + setBusy(true); + try { + const blob = await bakeCrop(sourceUrl, pixels); + onSave(blob, { aspect, orientation: portrait ? "portrait" : "landscape", pixels }); + } finally { setBusy(false); } + } + + return ( +
+
+ +
+
+
+ {PRESETS.map((a) => ( + + ))} + +
+
+ Zoom + setZoom(Number(e.target.value))} className="flex-1 accent-ih-primary" aria-label="Zoom" /> +
+
+ + +
+
+
+ ); +} diff --git a/app/components/image-studio/LogoUploader.tsx b/app/components/image-studio/LogoUploader.tsx new file mode 100644 index 000000000..1baf7eb52 --- /dev/null +++ b/app/components/image-studio/LogoUploader.tsx @@ -0,0 +1,33 @@ +import { useRef } from "react"; +export interface LogoUploaderProps { + currentUrl: string | null; + uploading: boolean; + onSelect: (file: File) => void; +} +/** Image Studio — company logo uploader. Logos keep their original format + * (transparent PNG / SVG): NO crop, NO bake. Just upload + fit preview. */ +export function LogoUploader({ currentUrl, uploading, onSelect }: LogoUploaderProps) { + const inputRef = useRef(null); + return ( +
+
+ {currentUrl ? ( + Logo + ) : ( +
+ +
+ )} +
+
+ { const f = e.target.files?.[0]; if (f) onSelect(f); e.target.value = ""; }} /> + +

PNG / SVG recommended (transparent)

+
+
+ ); +} diff --git a/app/components/image-studio/PhotoAnnotator.tsx b/app/components/image-studio/PhotoAnnotator.tsx new file mode 100644 index 000000000..79b8f1ee9 --- /dev/null +++ b/app/components/image-studio/PhotoAnnotator.tsx @@ -0,0 +1,762 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { Stage, Layer, Image as KonvaImage, Circle, Arrow, Line, Label, Tag, Text } from "react-konva"; +import type Konva from "konva"; +import { + ANNOTATION_COLOR, + deserializeAnnotations, + type Annotation, + type Point, +} from "./annotations"; + +/* ------------------------------------------------------------------ */ +/* Tool palette (ported from editor/PhotoStudio.tsx) */ +/* ------------------------------------------------------------------ */ + +const TOOLS = [ + { id: "pan", label: "Pan", icon: "M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122" }, + { id: "circle", label: "Circle", icon: "M21 12a9 9 0 11-18 0 9 9 0 0118 0z" }, + { id: "arrow", label: "Arrow", icon: "M4.5 19.5l15-15m0 0H8.25m11.25 0v11.25" }, + { id: "free", label: "Draw", icon: "M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.876-5.814a1.151 1.151 0 00-1.597-1.597L14.146 6.32a15.996 15.996 0 00-4.649 4.763m3.42 3.42a6.776 6.776 0 00-3.42-3.42" }, + { id: "text", label: "Label", icon: "M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" }, + { id: "measure", label: "Measure", icon: "M3 3h18M3 3v18M3 3l18 18M3 9h6M3 15h3M9 3v6M15 3v3" }, +] as const; + +type ToolId = (typeof TOOLS)[number]["id"]; + +const STROKE = 4; // logical px (natural-resolution); divided by scale when rendered +const CIRCLE_R = 40; // default circle radius in natural px + +interface PhotoAnnotatorProps { + open: boolean; + photoUrl: string | null; + photoIndex?: number; + totalPhotos?: number; + sectionName?: string; + initialAnnotationsJson?: string | null; + /** whether the open photo is the current report cover. */ + isCover?: boolean; + /** set the open photo as the report cover (omit to hide the control). */ + onSetCover?: () => void; + onSave: (data: { blob: Blob; nodesJson: string; caption: string }) => void; + onClose: () => void; +} + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function PhotoAnnotator({ + open, + photoUrl, + photoIndex, + totalPhotos, + sectionName, + initialAnnotationsJson, + isCover, + onSetCover, + onSave, + onClose, +}: PhotoAnnotatorProps) { + /* Client-only mount — konva touches the DOM (RR7 SSR safety) */ + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + const [tool, setTool] = useState("circle"); + const [zoom, setZoom] = useState(1); + const [annotations, setAnnotations] = useState([]); + const [caption, setCaption] = useState(sectionName || ""); + + // The loaded source image at natural resolution + the fit scale (natural -> display). + const [image, setImage] = useState(null); + const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 600, h: 400 }); + const [fitScale, setFitScale] = useState(1); + + // Two-click arrow / measure state (in natural px). + const [arrowStart, setArrowStart] = useState(null); + const [measureStart, setMeasureStart] = useState(null); + + // Freehand drawing (natural px). + const [freehandPoints, setFreehandPoints] = useState([]); + const [isDrawingFreehand, setIsDrawingFreehand] = useState(false); + + // Inline HTML label input — positioned in display px over the stage. + const [labelInput, setLabelInput] = useState(null); + const [labelText, setLabelText] = useState(""); + const labelInputRef = useRef(null); + + // Measure calibration. `pxPerUnit` null => uncalibrated (show raw px distance). + const [showCalibration, setShowCalibration] = useState(false); + const [pxPerUnit, setPxPerUnit] = useState(null); + const [calibLine, setCalibLine] = useState<{ a: Point; b: Point } | null>(null); + const [calibKnown, setCalibKnown] = useState(""); + const [calibUnit, setCalibUnit] = useState("in"); + // Committed measurements (kept in component state, not serialized — not part of Annotation). + const [measures, setMeasures] = useState>([]); + + const stageRef = useRef(null); + + /* The display scale combines fit-to-viewport and the zoom multiplier. */ + const scale = fitScale * zoom; + + /* -------------------------------------------------------------- */ + /* Reset on open + seed annotations */ + /* -------------------------------------------------------------- */ + useEffect(() => { + if (!open) return; + setAnnotations(deserializeAnnotations(initialAnnotationsJson)); + setCaption(sectionName || ""); + setZoom(1); + setTool("circle"); + setArrowStart(null); + setMeasureStart(null); + setFreehandPoints([]); + setIsDrawingFreehand(false); + setLabelInput(null); + setLabelText(""); + setShowCalibration(false); + setPxPerUnit(null); + setCalibLine(null); + setCalibKnown(""); + setMeasures([]); + }, [open, sectionName, initialAnnotationsJson]); + + /* -------------------------------------------------------------- */ + /* Load the source image at natural resolution + compute fit */ + /* -------------------------------------------------------------- */ + useEffect(() => { + if (!open || !mounted) return; + if (!photoUrl) { + setImage(null); + setNatural({ w: 600, h: 400 }); + setFitScale(1); + return; + } + const img = new window.Image(); + img.crossOrigin = "anonymous"; // allow toBlob export of cross-origin photos + img.onload = () => { + const w = img.naturalWidth || 600; + const h = img.naturalHeight || 400; + const maxW = window.innerWidth * 0.9; + const maxH = window.innerHeight * 0.7; + const fit = Math.min(maxW / w, maxH / h, 1); + setImage(img); + setNatural({ w, h }); + setFitScale(fit); + }; + img.src = photoUrl; + }, [open, mounted, photoUrl]); + + /* -------------------------------------------------------------- */ + /* Focus label input when it appears */ + /* -------------------------------------------------------------- */ + useEffect(() => { + if (labelInput) { + const t = setTimeout(() => labelInputRef.current?.focus(), 50); + return () => clearTimeout(t); + } + }, [labelInput]); + + /* -------------------------------------------------------------- */ + /* Escape to close (label input swallows it first) */ + /* -------------------------------------------------------------- */ + useEffect(() => { + if (!open) return; + function handleKey(e: KeyboardEvent) { + if (e.key !== "Escape") return; + if (labelInput) { + setLabelInput(null); + setLabelText(""); + return; + } + e.preventDefault(); + onClose(); + } + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [open, onClose, labelInput]); + + /* -------------------------------------------------------------- */ + /* Pointer -> natural px */ + /* -------------------------------------------------------------- */ + const naturalPointer = useCallback((): Point | null => { + const stage = stageRef.current; + if (!stage) return null; + const p = stage.getPointerPosition(); + if (!p) return null; + return { x: p.x / scale, y: p.y / scale }; + }, [scale]); + + /* -------------------------------------------------------------- */ + /* Stage click (circle / arrow / label / measure) */ + /* -------------------------------------------------------------- */ + const handleStageClick = useCallback(() => { + if (labelInput) return; + const pos = naturalPointer(); + if (!pos) return; + + if (tool === "circle") { + setAnnotations((prev) => [...prev, { kind: "circle", x: pos.x, y: pos.y, r: CIRCLE_R }]); + } else if (tool === "text") { + setLabelInput(pos); + setLabelText(""); + } else if (tool === "arrow") { + if (!arrowStart) { + setArrowStart(pos); + } else { + setAnnotations((prev) => [ + ...prev, + { kind: "arrow", x: arrowStart.x, y: arrowStart.y, x2: pos.x, y2: pos.y }, + ]); + setArrowStart(null); + } + } else if (tool === "measure") { + if (!measureStart) { + setMeasureStart(pos); + } else { + const line = { a: measureStart, b: pos }; + setMeasureStart(null); + if (pxPerUnit == null) { + // First measurement defines the calibration reference. + setCalibLine(line); + setShowCalibration(true); + } else { + setMeasures((prev) => [...prev, line]); + } + } + } + }, [tool, arrowStart, measureStart, pxPerUnit, naturalPointer, labelInput]); + + /* -------------------------------------------------------------- */ + /* Freehand: mousedown / move / up */ + /* -------------------------------------------------------------- */ + const handleMouseDown = useCallback(() => { + if (tool !== "free" || labelInput) return; + const pos = naturalPointer(); + if (!pos) return; + setFreehandPoints([pos]); + setIsDrawingFreehand(true); + }, [tool, labelInput, naturalPointer]); + + const handleMouseMove = useCallback(() => { + if (!isDrawingFreehand || tool !== "free") return; + const pos = naturalPointer(); + if (!pos) return; + setFreehandPoints((prev) => [...prev, pos]); + }, [isDrawingFreehand, tool, naturalPointer]); + + const handleMouseUp = useCallback(() => { + if (!isDrawingFreehand || tool !== "free") return; + if (freehandPoints.length > 1) { + setAnnotations((prev) => [ + ...prev, + { kind: "freehand", x: freehandPoints[0].x, y: freehandPoints[0].y, points: freehandPoints }, + ]); + } + setFreehandPoints([]); + setIsDrawingFreehand(false); + }, [isDrawingFreehand, tool, freehandPoints]); + + /* -------------------------------------------------------------- */ + /* Label commit */ + /* -------------------------------------------------------------- */ + const commitLabel = useCallback(() => { + if (labelInput && labelText.trim()) { + setAnnotations((prev) => [ + ...prev, + { kind: "label", x: labelInput.x, y: labelInput.y, text: labelText.trim() }, + ]); + } + setLabelInput(null); + setLabelText(""); + }, [labelInput, labelText]); + + /* -------------------------------------------------------------- */ + /* Calibration commit */ + /* -------------------------------------------------------------- */ + const commitCalibration = useCallback(() => { + const known = parseFloat(calibKnown); + if (calibLine && known > 0) { + const dx = calibLine.b.x - calibLine.a.x; + const dy = calibLine.b.y - calibLine.a.y; + const px = Math.sqrt(dx * dx + dy * dy); + if (px > 0) { + setPxPerUnit(px / known); + setMeasures((prev) => [...prev, calibLine]); + } + } + setShowCalibration(false); + setCalibLine(null); + setCalibKnown(""); + }, [calibLine, calibKnown]); + + /* -------------------------------------------------------------- */ + /* Undo / zoom */ + /* -------------------------------------------------------------- */ + const undoLast = useCallback(() => setAnnotations((prev) => prev.slice(0, -1)), []); + const zoomIn = useCallback(() => setZoom((z) => Math.min(z + 0.25, 4)), []); + const zoomOut = useCallback(() => setZoom((z) => Math.max(z - 0.25, 0.5)), []); + const zoomReset = useCallback(() => setZoom(1), []); + + /* -------------------------------------------------------------- */ + /* Save — export stage to PNG at NATURAL resolution */ + /* -------------------------------------------------------------- */ + const handleSave = useCallback(async () => { + const stage = stageRef.current; + if (!stage) return; + // pixelRatio 1/scale maps the scaled-down display stage back to natural px. + // konva@10 toBlob returns a Promise (callback optional). + let blob: Blob | null = null; + try { + blob = (await stage.toBlob({ pixelRatio: 1 / scale, mimeType: "image/png" })) as Blob | null; + } catch { + // Fallback for environments where toBlob is unavailable on the Stage. + const canvas = stage.toCanvas({ pixelRatio: 1 / scale }); + blob = await new Promise((resolve) => + canvas.toBlob((b) => resolve(b), "image/png"), + ); + } + if (!blob) return; + onSave({ blob, nodesJson: JSON.stringify(annotations), caption }); + }, [annotations, caption, scale, onSave]); + + /* -------------------------------------------------------------- */ + /* Render */ + /* -------------------------------------------------------------- */ + if (!open || !mounted) return null; + + const stageW = natural.w * scale; + const stageH = natural.h * scale; + const sw = STROKE / scale; // stroke width in natural px so it looks constant on screen + + const fmtDistance = (a: Point, b: Point) => { + const px = Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2); + if (pxPerUnit && pxPerUnit > 0) { + return `${(px / pxPerUnit).toFixed(1)} ${calibUnit}`; + } + return `${Math.round(px)} px`; + }; + + return ( + /* ds-allow: fixed-dark photo-studio chrome (white/* neutrals + amber-400 hints stay dark in both themes) */ +
+ {/* -------------------------------------------------------- */} + {/* Top bar */} + {/* -------------------------------------------------------- */} +
+ + +
+ + {photoIndex != null && totalPhotos != null && totalPhotos > 0 + ? `Photo ${photoIndex} of ${totalPhotos}` + : "Photo Studio"} + + {arrowStart && ( + Click to set arrow endpoint + )} + {measureStart && ( + Click to set measurement endpoint + )} + {tool === "free" && !isDrawingFreehand && ( + Click and drag to draw + )} +
+ + {/* set the open photo as the report cover */} + {onSetCover && photoUrl && ( + + )} + + + + +
+ + {/* -------------------------------------------------------- */} + {/* Canvas area */} + {/* -------------------------------------------------------- */} +
+
+ {photoUrl && image ? ( +
+ + {/* Background image layer */} + + + + + {/* Annotation layer (drawn in natural px, scaled by the stage's group) */} + + {annotations.map((ann, i) => { + if (ann.kind === "circle") { + return ( + + ); + } + if (ann.kind === "arrow") { + return ( + + ); + } + if (ann.kind === "freehand" && ann.points.length > 1) { + return ( + [p.x, p.y])} + stroke={ANNOTATION_COLOR} + strokeWidth={sw} + lineCap="round" + lineJoin="round" + tension={0.4} + /> + ); + } + if (ann.kind === "label") { + return ( + + ); + } + return null; + })} + + {/* Active arrow start marker */} + {arrowStart && } + + {/* Active measure start marker */} + {measureStart && } + + {/* Active freehand preview */} + {isDrawingFreehand && freehandPoints.length > 1 && ( + [p.x, p.y])} + stroke={ANNOTATION_COLOR} + strokeWidth={sw} + lineCap="round" + lineJoin="round" + opacity={0.6} + /> + )} + + {/* Committed measurements: line + distance text */} + {measures.map((m, i) => ( + + ))} + {measures.map((m, i) => ( + + ))} + + + + {/* Inline HTML label input — positioned in display px over the stage */} + {labelInput && ( +
+
+ setLabelText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitLabel(); + } + if (e.key === "Escape") { + e.preventDefault(); + setLabelInput(null); + setLabelText(""); + } + e.stopPropagation(); + }} + placeholder="Enter label..." + className="w-40 h-7 px-2 rounded bg-slate-700 text-white text-[12px] border border-white/10 outline-none focus:border-ih-primary placeholder-white/30" + /> +
+ + +
+
+
+ )} +
+ ) : ( +
+
+ + + +

No photo selected

+

Take or upload a photo to annotate

+
+
+ )} +
+ + {/* Zoom controls (right side) */} +
+ + + +
+ + {/* Annotation count badge */} + {annotations.length > 0 && ( +
+ + + + + {annotations.length} annotation{annotations.length !== 1 ? "s" : ""} + +
+ )} + + {/* Calibration overlay (measure tool) */} + {showCalibration && ( +
+
+ + + + Reference length: + setCalibKnown(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitCalibration(); + } + e.stopPropagation(); + }} + placeholder="e.g. 12" + className="w-20 h-7 px-2 rounded bg-slate-700 text-white text-[12px] border border-white/10 outline-none focus:border-ih-primary placeholder-white/30" + /> + + + +
+
+ )} +
+ + {/* -------------------------------------------------------- */} + {/* Bottom tool palette */} + {/* -------------------------------------------------------- */} +
+
+ {TOOLS.map((t) => ( + + ))} +
+ +
+ +
+ setCaption(e.target.value)} + placeholder="Add a caption..." + className="w-full h-8 px-3 rounded-md bg-white/5 border border-white/10 text-white text-[12px] placeholder-white/30 outline-none focus:border-ih-primary transition-colors" + /> +
+
+
+ ); +} diff --git a/app/components/image-studio/PhotoGallery.tsx b/app/components/image-studio/PhotoGallery.tsx new file mode 100644 index 000000000..db6a72420 --- /dev/null +++ b/app/components/image-studio/PhotoGallery.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from "react"; +import { useFetcher } from "react-router"; +import { PhotoGrid } from "./PhotoGrid"; +import { PhotoLightbox } from "./PhotoLightbox"; +import { fullResUrl } from "./cropImage"; +import type { GalleryPhoto } from "~/lib/inspection-media"; + +export interface PhotoGalleryProps { + inspectionId: string; + onSetCover: (photo: { key: string; url: string }) => void; + onAnnotate: (photo: { key: string; url: string }) => void; +} +export function PhotoGallery({ inspectionId, onSetCover, onAnnotate }: PhotoGalleryProps) { + const load = useFetcher<{ photos: GalleryPhoto[] }>(); + const [lightbox, setLightbox] = useState(null); + const photos = load.data?.photos ?? []; + useEffect(() => { + if (inspectionId) load.load(`/resources/inspection-media?inspectionId=${encodeURIComponent(inspectionId)}`); + }, [inspectionId]); + if (load.state === "loading" && photos.length === 0) return

Loading photos…

; + if (photos.length === 0) return

No photos in this inspection yet.

; + // Action buttons live in the lightbox toolbar so they act on the photo being + // viewed (the fullscreen YARL overlay covers any side-panel controls). + const viewed = lightbox !== null ? photos[lightbox] : undefined; + const toolbarButtons = viewed + ? [ + , + , + ] + : undefined; + return ( +
+ ({ key: p.key, src: p.url, width: 4, height: 3, label: p.label }))} onClick={(i) => setLightbox(i)} /> + ({ src: fullResUrl(p.url), alt: p.label }))} + index={lightbox ?? 0} + open={lightbox !== null} + onClose={() => setLightbox(null)} + toolbarButtons={toolbarButtons} + /> +
+ ); +} diff --git a/app/components/image-studio/PhotoGrid.tsx b/app/components/image-studio/PhotoGrid.tsx new file mode 100644 index 000000000..9f183c692 --- /dev/null +++ b/app/components/image-studio/PhotoGrid.tsx @@ -0,0 +1,14 @@ +import { RowsPhotoAlbum } from "react-photo-album"; +import "react-photo-album/rows.css"; +export interface GridItem { key: string; src: string; width: number; height: number; label?: string } +/** Isolation wrapper around react-photo-album (single-maintainer dep). */ +export function PhotoGrid({ items, onClick }: { items: GridItem[]; onClick: (index: number) => void }) { + return ( + ({ key: it.key, src: it.src, width: it.width, height: it.height, alt: it.label ?? "" }))} + targetRowHeight={96} + spacing={6} + onClick={({ index }) => onClick(index)} + /> + ); +} diff --git a/app/components/image-studio/PhotoLightbox.tsx b/app/components/image-studio/PhotoLightbox.tsx new file mode 100644 index 000000000..41ec06c89 --- /dev/null +++ b/app/components/image-studio/PhotoLightbox.tsx @@ -0,0 +1,19 @@ +import Lightbox from "yet-another-react-lightbox"; +import "yet-another-react-lightbox/styles.css"; +export interface LightboxSlide { src: string; alt?: string } +/** Isolation wrapper around yet-another-react-lightbox (single-maintainer dep). */ +export function PhotoLightbox({ slides, index, open, onClose, toolbarButtons }: { + slides: LightboxSlide[]; index: number; open: boolean; onClose: () => void; + /** Custom toolbar nodes rendered before the built-in Close button (YARL v3 `toolbar.buttons`). */ + toolbarButtons?: React.ReactNode[]; +}) { + return ( + + ); +} diff --git a/app/components/image-studio/annotations.ts b/app/components/image-studio/annotations.ts new file mode 100644 index 000000000..232ab4bf6 --- /dev/null +++ b/app/components/image-studio/annotations.ts @@ -0,0 +1,16 @@ +/** + * Image Studio — annotation model. Coords are NATURAL-IMAGE PIXELS (resolution- + * stable). `annotationsJson` stored server-side is the JSON of this array. + */ +export interface Point { x: number; y: number } +export type Annotation = + | { kind: 'circle'; x: number; y: number; r: number } + | { kind: 'arrow'; x: number; y: number; x2: number; y2: number } + | { kind: 'label'; x: number; y: number; text: string } + | { kind: 'freehand'; x: number; y: number; points: Point[] }; +export const ANNOTATION_COLOR = '#ef4444'; +export function serializeAnnotations(anns: Annotation[]): string { return JSON.stringify(anns); } +export function deserializeAnnotations(json: string | null | undefined): Annotation[] { + if (!json) return []; + try { const p = JSON.parse(json); return Array.isArray(p) ? (p as Annotation[]) : []; } catch { return []; } +} diff --git a/app/components/image-studio/cropImage.ts b/app/components/image-studio/cropImage.ts new file mode 100644 index 000000000..6cfb126e5 --- /dev/null +++ b/app/components/image-studio/cropImage.ts @@ -0,0 +1,40 @@ +/** + * Image Studio (cover crop) — draw the chosen crop region of a source image to + * a canvas and export a JPEG blob whose LONG edge is at most `maxLongEdge` (no + * upscale). Source URL must be same-origin (authed photo route) so the canvas + * is not tainted. + */ +export interface PixelCrop { x: number; y: number; width: number; height: number } + +const MAX_LONG_EDGE = 2048; +const JPEG_QUALITY = 0.82; + +function loadImage(url: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = url; + }); +} + +/** Strip a `?w=`/`&w=` thumbnail param so we bake from the full-resolution original. */ +export function fullResUrl(url: string): string { + return url.replace(/([?&])w=\d+(&|$)/, (_m, p1, p2) => (p2 === '&' ? p1 : '')).replace(/[?&]$/, ''); +} + +export async function bakeCrop(sourceUrl: string, crop: PixelCrop, maxLongEdge = MAX_LONG_EDGE): Promise { + const img = await loadImage(fullResUrl(sourceUrl)); + const scale = Math.min(1, maxLongEdge / Math.max(crop.width, crop.height)); + const outW = Math.round(crop.width * scale); + const outH = Math.round(crop.height * scale); + const canvas = document.createElement('canvas'); + canvas.width = outW; + canvas.height = outH; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('canvas 2d context unavailable'); + ctx.drawImage(img, crop.x, crop.y, crop.width, crop.height, 0, 0, outW, outH); + return await new Promise((resolve, reject) => + canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/jpeg', JPEG_QUALITY), + ); +} diff --git a/app/lib/inspection-media.ts b/app/lib/inspection-media.ts new file mode 100644 index 000000000..ff51c01e3 --- /dev/null +++ b/app/lib/inspection-media.ts @@ -0,0 +1,23 @@ +/** + * Image Studio — flatten the Media Center API ({attached, pool}) into one + * deduped, labeled photo list for the gallery + cover picker. Dedup by R2 key. + */ +export interface GalleryPhoto { key: string; url: string; label: string } +export interface MediaApiBody { + data?: { + attached?: Array<{ key: string; url: string; itemLabel?: string }>; + pool?: Array<{ key: string; url: string }>; + }; +} +export function flattenMedia(body: MediaApiBody | null | undefined): GalleryPhoto[] { + const out: GalleryPhoto[] = []; + const seen = new Set(); + const push = (key?: string, url?: string, label = '') => { + if (!key || !url || seen.has(key)) return; + seen.add(key); + out.push({ key, url, label }); + }; + for (const a of body?.data?.attached ?? []) push(a?.key, a?.url, a?.itemLabel ?? ''); + for (const p of body?.data?.pool ?? []) push(p?.key, p?.url, 'Unattached'); + return out; +} diff --git a/app/routes.ts b/app/routes.ts index 8daa7de8a..33612d914 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -87,6 +87,7 @@ export default [ route("resources/identities", "routes/resources/identities.tsx"), route("resources/inspection-prefs", "routes/resources/inspection-prefs.tsx"), route("resources/inspection-settings-sheet", "routes/resources/inspection-settings-sheet.tsx"), + route("resources/inspection-media", "routes/resources/inspection-media.tsx"), 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"), diff --git a/app/routes/inspection-edit.tsx b/app/routes/inspection-edit.tsx index c0011f29a..aa60ec142 100644 --- a/app/routes/inspection-edit.tsx +++ b/app/routes/inspection-edit.tsx @@ -39,9 +39,11 @@ import { FooterBar } from "~/components/editor/FooterBar"; import { KeyboardHud } from "~/components/editor/KeyboardHud"; import { InspectorToolsDock } from "~/components/editor/InspectorToolsDock"; import { BurstCamera } from "~/components/editor/BurstCamera"; -import { PhotoStudio } from "~/components/editor/PhotoStudio"; +import { PhotoAnnotator } from "~/components/image-studio/PhotoAnnotator"; import { PropertyInfoForm } from "~/components/editor/PropertyInfoForm"; import { InspectionSettingsSheet } from "~/components/editor/InspectionSettingsSheet"; +import { CoverCropper } from "~/components/image-studio/CoverCropper"; +import { fullResUrl } from "~/components/image-studio/cropImage"; import { SignaturePad } from "~/components/SignaturePad"; import { PublishGateModal } from "~/components/editor/PublishGateModal"; import { ToastPortal } from "~/components/Toast"; @@ -314,6 +316,33 @@ export async function action({ request, params, context }: Route.ActionArgs) { return { ok: patch.ok, intent: "upload-cover", coverKey: key, coverUrl: body.data?.url ?? null }; } + if (intent === "crop-cover") { + const image = formData.get("image"); + const sourceKey = String(formData.get("sourceKey") ?? ""); + const crop = String(formData.get("crop") ?? ""); + if (!(image instanceof File) || !sourceKey) return { ok: false as const, intent: "crop-cover" }; + const res = await api.inspections[":id"].cover.$post({ + param: { id: params.id }, + form: { image, sourceKey, crop }, + }); + const body = (await res.json().catch(() => null)) as { data?: { coverImageKey?: string } } | null; + return { ok: res.ok, intent: "crop-cover", coverKey: body?.data?.coverImageKey ?? null }; + } + + if (intent === "annotate") { + const image = formData.get("image"); + const itemId = String(formData.get("itemId") ?? ""); + const photoIndex = Number(formData.get("photoIndex") ?? "-1"); + const nodes = String(formData.get("nodes") ?? "[]"); + const sectionId = String(formData.get("sectionId") ?? ""); + if (!(image instanceof File) || !itemId || photoIndex < 0) return { ok: false as const, intent: "annotate" }; + const res = await api.inspections[":id"].items[":itemId"].photos[":photoIndex"].annotation.$post({ + param: { id: params.id, itemId, photoIndex: String(photoIndex) }, + form: sectionId ? { image, nodes, sectionId } : { image, nodes }, + }); + return { ok: res.ok, intent: "annotate" }; + } + if (intent === "toggle-auto-sign") { const autoSignOnPublish = formData.get("autoSignOnPublish") === "true"; const res = await api.inspections[":id"].$patch({ @@ -887,6 +916,8 @@ export default function InspectionEditPage() { // DB-16 — dedicated fetcher for set/clear report cover (avoids the // shared-fetcher abort hazard; the loader revalidates the cover after). const coverFetcher = useFetcher(); + // Image Studio — gallery "Set as cover" opens an editor-level CoverCropper. + const [galleryCropSource, setGalleryCropSource] = useState<{ key: string; url: string } | null>(null); /* Mobile shell state */ const isMobile = useIsMobile(); @@ -1593,6 +1624,14 @@ export default function InspectionEditPage() { getRatingColor={state.getRatingColor} getRatingLabel={state.getRatingLabel} inspectionId={String(state.inspection.id)} + onGallerySetCover={(p) => setGalleryCropSource(p)} + onGalleryAnnotate={(p) => { + setPhotoStudioUrl(p.url); + setPhotoStudioKey(p.key); + setPhotoStudioIndex(0); + setPhotoStudioTotal(0); + setPhotoStudioOpen(true); + }} /> ); @@ -1768,12 +1807,13 @@ export default function InspectionEditPage() { /> {/* Photo studio overlay */} - { const isCover = (state.inspection.coverPhotoId as string | null) === photoStudioKey; @@ -1782,7 +1822,18 @@ export default function InspectionEditPage() { { method: "post" }, ); } : undefined} - onSave={() => { + onSave={({ blob, nodesJson }) => { + const itemId = state.activeItemId; + if (itemId && photoStudioIndex != null) { + const fd = new FormData(); + fd.append("intent", "annotate"); + fd.append("itemId", itemId); + fd.append("photoIndex", String(photoStudioIndex)); + fd.append("nodes", nodesJson); + if (state.currentSection?.id) fd.append("sectionId", state.currentSection.id); + fd.append("image", new File([blob], "annotated.png", { type: "image/png" })); + coverFetcher.submit(fd, { method: "post", encType: "multipart/form-data" }); + } setPhotoStudioOpen(false); }} onClose={() => setPhotoStudioOpen(false)} @@ -1799,6 +1850,24 @@ export default function InspectionEditPage() { onTemplateApplied={() => window.location.reload()} /> + {/* Image Studio — gallery "Set as cover" crop overlay */} + {galleryCropSource && ( + setGalleryCropSource(null)} + onSave={(blob, c) => { + const fd = new FormData(); + fd.append("intent", "crop-cover"); + fd.append("sourceKey", galleryCropSource.key); + fd.append("crop", JSON.stringify({ aspect: c.aspect, orientation: c.orientation, ...c.pixels })); + fd.append("image", new File([blob], "cover.jpg", { type: "image/jpeg" })); + coverFetcher.submit(fd, { method: "post", encType: "multipart/form-data" }); + setGalleryCropSource(null); + }} + /> + )} + {/* Unsaved changes blocker dialog */} {blocker.state === "blocked" && (
diff --git a/app/routes/resources/inspection-media.tsx b/app/routes/resources/inspection-media.tsx new file mode 100644 index 000000000..0b72f0c3c --- /dev/null +++ b/app/routes/resources/inspection-media.tsx @@ -0,0 +1,32 @@ +/** + * Image Studio — BFF resource route for the unified photo gallery. + * + * loader: bundles GET /api/inspections/:id/media into a deduped, labeled photo + * list (flattenMedia) so the gallery component has no raw client-side + * fetches. Mirrors inspection-settings-sheet's token/createApi pattern. + */ +import type { Route } from "./+types/inspection-media"; +import { getToken } from "~/lib/session.server"; +import { createApi } from "~/lib/api-client.server"; +import { flattenMedia, type GalleryPhoto } from "~/lib/inspection-media"; + +export async function loader({ request, context }: Route.LoaderArgs): Promise<{ photos: GalleryPhoto[] }> { + const token = await getToken(context, request); + if (!token) return { photos: [] }; + const inspectionId = new URL(request.url).searchParams.get("inspectionId") ?? ""; + if (!inspectionId) return { photos: [] }; + const api = createApi(context, { token }); + const hdr = { headers: { "x-token-relay": "1" } } as const; + const res = await api.inspections[":id"].media.$get({ param: { id: inspectionId } }, hdr).catch(() => null); + const body = res?.ok ? ((await res.json()) as Parameters[0]) : null; + // Gallery thumbnails request a larger width (?w=480) than the cover grid + // (?w=240) since the lightbox shows photos at a meaningful size. + const thumb = (url: string) => (url.includes("?") ? `${url}&w=480` : `${url}?w=480`); + return { photos: flattenMedia(body).map((p) => ({ ...p, url: thumb(p.url) })) }; +} + +// The gallery loads this once when it opens; gate automatic revalidation the +// same way the settings sheet does to avoid flicker on editor mutations. +export function shouldRevalidate() { + return false; +} diff --git a/app/routes/resources/inspection-settings-sheet.tsx b/app/routes/resources/inspection-settings-sheet.tsx index a245e6cd4..ce0923371 100644 --- a/app/routes/resources/inspection-settings-sheet.tsx +++ b/app/routes/resources/inspection-settings-sheet.tsx @@ -8,6 +8,7 @@ import type { Route } from "./+types/inspection-settings-sheet"; import { getToken } from "~/lib/session.server"; import { createApi } from "~/lib/api-client.server"; +import { flattenMedia } from "~/lib/inspection-media"; interface Template { id: string; @@ -71,30 +72,16 @@ export async function loader({ request, context }: Route.LoaderArgs) { } // DB-16 — flatten attached + pool photos into one pickable cover list. - // Dedup by R2 key: results.data can store an item under BOTH a composite - // (unit::section::item) and a bare itemId key, which makes the media center - // surface the same photo twice; the cover grid must show each photo once. - const photos: CoverPhoto[] = []; - const seen = new Set(); + // Dedup by R2 key (shared flattenMedia helper): results.data can store an + // item under BOTH a composite (unit::section::item) and a bare itemId key, + // which makes the media center surface the same photo twice; the cover grid + // must show each photo once. // Request small thumbnails (?w=240) for the grid so the browser doesn't pull // full-resolution originals; the photo endpoint resizes when CF Images is // available and falls back to the original otherwise. const thumb = (url: string) => (url.includes("?") ? `${url}&w=240` : `${url}?w=240`); - const pushPhoto = (key?: string, url?: string, label = "") => { - if (!key || !url || seen.has(key)) return; - seen.add(key); - photos.push({ key, url: thumb(url), label }); - }; - if (mediaRes?.ok) { - const body = (await mediaRes.json()) as { - data?: { - attached?: Array<{ key: string; url: string; itemLabel?: string }>; - pool?: Array<{ key: string; url: string }>; - }; - }; - for (const a of body?.data?.attached ?? []) pushPhoto(a?.key, a?.url, a?.itemLabel ?? ""); - for (const p of body?.data?.pool ?? []) pushPhoto(p?.key, p?.url, "Unattached"); - } + const mediaBody = mediaRes?.ok ? ((await mediaRes.json()) as Parameters[0]) : null; + const photos: CoverPhoto[] = flattenMedia(mediaBody).map((p) => ({ key: p.key, url: thumb(p.url), label: p.label })); return { inspection, templates, members, photos }; } diff --git a/app/routes/settings-profile.tsx b/app/routes/settings-profile.tsx index 2edda771e..7f187c73e 100644 --- a/app/routes/settings-profile.tsx +++ b/app/routes/settings-profile.tsx @@ -6,6 +6,7 @@ import type { Route } from "./+types/settings-profile"; import { requireToken } from "~/lib/session.server"; import { createApi } from "~/lib/api-client.server"; import { SignaturePad } from "~/components/SignaturePad"; +import { AvatarCropper } from "~/components/image-studio/AvatarCropper"; import { profileSchema } from "~/lib/forms/settings.schema"; /* ------------------------------------------------------------------ */ @@ -112,6 +113,7 @@ export default function SettingsProfilePage() { const { profile } = useLoaderData(); const actionData = useActionData(); const [bioLen, setBioLen] = useState((profile.bio ?? "").length); + const [avatarSource, setAvatarSource] = useState(null); // DB-12 / IA-26 — useSessionContext / tenantSlug removed; slug section gone. // Conform owns the main profile form (default intent). The save-signature @@ -246,11 +248,8 @@ export default function SettingsProfilePage() { className="block text-[11px] text-ih-fg-3" onChange={(e) => { const file = e.target.files?.[0]; - if (!file) return; - const fd = new FormData(); - fd.append("intent", "photo-upload"); - fd.append("photo", file); - photoFetcher.submit(fd, { method: "POST", encType: "multipart/form-data" }); + if (file) setAvatarSource(URL.createObjectURL(file)); + e.target.value = ""; }} />

JPG, PNG, or WebP. Max 2 MB. Square crop renders best.

@@ -360,6 +359,21 @@ export default function SettingsProfilePage() { )} + + {avatarSource && ( + { URL.revokeObjectURL(avatarSource); setAvatarSource(null); }} + onSave={(blob) => { + const fd = new FormData(); + fd.append("intent", "photo-upload"); + fd.append("photo", new File([blob], "avatar.jpg", { type: "image/jpeg" })); + photoFetcher.submit(fd, { method: "POST", encType: "multipart/form-data" }); + URL.revokeObjectURL(avatarSource); + setAvatarSource(null); + }} + /> + )}
); } diff --git a/app/routes/settings-workspace.tsx b/app/routes/settings-workspace.tsx index b7a3de231..6d8268100 100644 --- a/app/routes/settings-workspace.tsx +++ b/app/routes/settings-workspace.tsx @@ -1,10 +1,11 @@ -import { useState } from "react"; -import { Form, Link, useLoaderData, useActionData } from "react-router"; +import { useState, useEffect } from "react"; +import { Form, Link, useLoaderData, useActionData, useFetcher } from "react-router"; import { useForm } from "@conform-to/react"; import { parseWithZod } from "@conform-to/zod/v4"; import type { Route } from "./+types/settings-workspace"; import { requireToken } from "~/lib/session.server"; import { createApi } from "~/lib/api-client.server"; +import { LogoUploader } from "~/components/image-studio/LogoUploader"; import { workspaceSchema } from "~/lib/forms/settings.schema"; /* ------------------------------------------------------------------ */ @@ -40,6 +41,19 @@ export async function loader({ request, context }: Route.LoaderArgs) { export async function action({ request, context }: Route.ActionArgs) { const token = await requireToken(context, request); const fd = await request.formData(); + + const intent = fd.get("intent") as string | null; + if (intent === "logo-upload") { + const logo = fd.get("logo"); + if (!(logo instanceof File) || logo.size === 0) { + return { success: false, error: "No valid logo provided", intent }; + } + const api = createApi(context, { token }); + const res = await api.adminBranding.branding.logo.$post({ form: { logo } }); + const body = (await res.json().catch(() => null)) as { data?: { logoUrl?: string } } | null; + return { success: res.ok, intent, logoUrl: body?.data?.logoUrl ?? null }; + } + const submission = parseWithZod(fd, { schema: workspaceSchema }); if (submission.status !== "success") { return submission.reply(); @@ -81,6 +95,13 @@ export default function SettingsWorkspacePage() { const actionData = useActionData(); const [color, setColor] = useState(branding.primaryColor ?? "#6366f1"); + const logoFetcher = useFetcher<{ success: boolean; intent?: string; logoUrl?: string | null }>(); + const [logoUrl, setLogoUrl] = useState(branding.logoUrl ?? null); + useEffect(() => { + const d = logoFetcher.data; + if (logoFetcher.state === "idle" && d?.intent === "logo-upload" && d.success && d.logoUrl) setLogoUrl(d.logoUrl); + }, [logoFetcher.state, logoFetcher.data]); + const [form, fields] = useForm({ lastResult: actionData && "status" in actionData ? actionData : undefined, onValidate({ formData }) { @@ -146,21 +167,16 @@ export default function SettingsWorkspacePage() { {/* Logo upload */}
-
-
- {branding.logoUrl ? ( - Logo - ) : ( -
- -
- )} -
-
- -

PNG / SVG recommended

-
-
+ { + const fd = new FormData(); + fd.append("intent", "logo-upload"); + fd.append("logo", file); + logoFetcher.submit(fd, { method: "POST", encType: "multipart/form-data" }); + }} + />
diff --git a/migrations/0006_bent_gamma_corps.sql b/migrations/0006_bent_gamma_corps.sql new file mode 100644 index 000000000..0e79aa16b --- /dev/null +++ b/migrations/0006_bent_gamma_corps.sql @@ -0,0 +1,2 @@ +ALTER TABLE `inspections` ADD `cover_crop` text;--> statement-breakpoint +ALTER TABLE `inspections` ADD `cover_image_key` text; \ No newline at end of file diff --git a/migrations/meta/0006_snapshot.json b/migrations/meta/0006_snapshot.json new file mode 100644 index 000000000..d75309631 --- /dev/null +++ b/migrations/meta/0006_snapshot.json @@ -0,0 +1,7817 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6cfce7ec-f25e-418f-ab33-35dd1e338f47", + "prevId": "b3d2164c-9f25-4eac-95b3-f1f6b6993e5e", + "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": {} + }, + "automation_logs": { + "name": "automation_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'email'" + }, + "send_at": { + "name": "send_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_logs_pending": { + "name": "idx_automation_logs_pending", + "columns": [ + "tenant_id", + "status", + "send_at" + ], + "isUnique": false + }, + "idx_automation_logs_insp": { + "name": "idx_automation_logs_insp", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "uq_automation_logs_event": { + "name": "uq_automation_logs_event", + "columns": [ + "automation_id", + "inspection_id", + "event_id" + ], + "isUnique": true, + "where": "event_id IS NOT NULL" + } + }, + "foreignKeys": { + "automation_logs_tenant_id_tenants_id_fk": { + "name": "automation_logs_tenant_id_tenants_id_fk", + "tableFrom": "automation_logs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automations": { + "name": "automations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient": { + "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 + }, + "channels": { + "name": "channels", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"email\"]'" + }, + "sms_body": { + "name": "sms_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_automations_tenant": { + "name": "idx_automations_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automations_tenant_id_tenants_id_fk": { + "name": "automations_tenant_id_tenants_id_fk", + "tableFrom": "automations", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "availability": { + "name": "availability", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_availability_inspector": { + "name": "idx_availability_inspector", + "columns": [ + "inspector_id" + ], + "isUnique": false + }, + "idx_availability_window_unique": { + "name": "idx_availability_window_unique", + "columns": [ + "inspector_id", + "day_of_week", + "start_time" + ], + "isUnique": true + } + }, + "foreignKeys": { + "availability_tenant_id_tenants_id_fk": { + "name": "availability_tenant_id_tenants_id_fk", + "tableFrom": "availability", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "availability_inspector_id_users_id_fk": { + "name": "availability_inspector_id_users_id_fk", + "tableFrom": "availability", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "availability_overrides": { + "name": "availability_overrides", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_available": { + "name": "is_available", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "start_time": { + "name": "start_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_avail_overrides_insp": { + "name": "idx_avail_overrides_insp", + "columns": [ + "inspector_id" + ], + "isUnique": false + }, + "idx_avail_overrides_block_unique": { + "name": "idx_avail_overrides_block_unique", + "columns": [ + "inspector_id", + "date" + ], + "isUnique": true, + "where": "is_available = 0" + } + }, + "foreignKeys": { + "availability_overrides_tenant_id_tenants_id_fk": { + "name": "availability_overrides_tenant_id_tenants_id_fk", + "tableFrom": "availability_overrides", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "availability_overrides_inspector_id_users_id_fk": { + "name": "availability_overrides_inspector_id_users_id_fk", + "tableFrom": "availability_overrides", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comment_usage": { + "name": "comment_usage", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_comment_usage_user_last_used": { + "name": "idx_comment_usage_user_last_used", + "columns": [ + "tenant_id", + "user_id", + "last_used_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "comment_usage_comment_id_comments_id_fk": { + "name": "comment_usage_comment_id_comments_id_fk", + "tableFrom": "comment_usage", + "tableTo": "comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "comment_usage_tenant_id_user_id_comment_id_pk": { + "columns": [ + "tenant_id", + "user_id", + "comment_id" + ], + "name": "comment_usage_tenant_id_user_id_comment_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rating_bucket": { + "name": "rating_bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section": { + "name": "section", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_ids": { + "name": "section_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_labels": { + "name": "item_labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger_code": { + "name": "trigger_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "search_keywords": { + "name": "search_keywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_label": { + "name": "item_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repair_summary": { + "name": "repair_summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimate_min_cents": { + "name": "estimate_min_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimate_max_cents": { + "name": "estimate_max_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recommended_contractor_type_id": { + "name": "recommended_contractor_type_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_comments_tenant": { + "name": "idx_comments_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_comments_rating_bucket": { + "name": "idx_comments_rating_bucket", + "columns": [ + "tenant_id", + "rating_bucket" + ], + "isUnique": false + }, + "idx_comments_library_id": { + "name": "idx_comments_library_id", + "columns": [ + "library_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "comments_tenant_id_tenants_id_fk": { + "name": "comments_tenant_id_tenants_id_fk", + "tableFrom": "comments", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commercial_subtypes": { + "name": "commercial_subtypes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "based_on": { + "name": "based_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disabled": { + "name": "disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_commercial_subtypes_tenant_name": { + "name": "idx_commercial_subtypes_tenant_name", + "columns": [ + "tenant_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "commercial_subtypes_tenant_id_tenants_id_fk": { + "name": "commercial_subtypes_tenant_id_tenants_id_fk", + "tableFrom": "commercial_subtypes", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "concierge_confirm_tokens": { + "name": "concierge_confirm_tokens", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_concierge_tokens_expiry": { + "name": "idx_concierge_tokens_expiry", + "columns": [ + "expires_at" + ], + "isUnique": false + }, + "idx_concierge_confirm_token_hash": { + "name": "idx_concierge_confirm_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "concierge_confirm_tokens_inspection_id_inspections_id_fk": { + "name": "concierge_confirm_tokens_inspection_id_inspections_id_fk", + "tableFrom": "concierge_confirm_tokens", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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": {} + }, + "contractor_types": { + "name": "contractor_types", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_contractor_types_tenant": { + "name": "idx_contractor_types_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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": {} + }, + "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_cents": { + "name": "total_amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unpaid'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_requests_tenant": { + "name": "idx_inspection_requests_tenant", + "columns": [ + "tenant_id", + "status", + "scheduled_at" + ], + "isUnique": false + }, + "idx_inspection_requests_email": { + "name": "idx_inspection_requests_email", + "columns": [ + "tenant_id", + "client_email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspection_requests_tenant_id_tenants_id_fk": { + "name": "inspection_requests_tenant_id_tenants_id_fk", + "tableFrom": "inspection_requests", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_results": { + "name": "inspection_results", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating_system_id": { + "name": "rating_system_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rating_system_snapshot": { + "name": "rating_system_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_results_tenant": { + "name": "idx_results_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_results_inspection": { + "name": "idx_results_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "uq_results_inspection": { + "name": "uq_results_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "inspection_results_tenant_id_tenants_id_fk": { + "name": "inspection_results_tenant_id_tenants_id_fk", + "tableFrom": "inspection_results", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_results_inspection_id_inspections_id_fk": { + "name": "inspection_results_inspection_id_inspections_id_fk", + "tableFrom": "inspection_results", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_services": { + "name": "inspection_services", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_id": { + "name": "service_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_override_cents": { + "name": "price_override_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name_snapshot": { + "name": "name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_snapshot_cents": { + "name": "price_snapshot_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_insp_services_tenant": { + "name": "idx_insp_services_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_insp_services_insp": { + "name": "idx_insp_services_insp", + "columns": [ + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspection_services_tenant_id_tenants_id_fk": { + "name": "inspection_services_tenant_id_tenants_id_fk", + "tableFrom": "inspection_services", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_services_inspection_id_inspections_id_fk": { + "name": "inspection_services_inspection_id_inspections_id_fk", + "tableFrom": "inspection_services", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inspection_services_service_id_services_id_fk": { + "name": "inspection_services_service_id_services_id_fk", + "tableFrom": "inspection_services", + "tableTo": "services", + "columnsFrom": [ + "service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_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_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "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_cents": { + "name": "discount_amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "closing_date": { + "name": "closing_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referral_source": { + "name": "referral_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 + }, + "cover_crop": { + "name": "cover_crop", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_image_key": { + "name": "cover_image_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commercial_subtype": { + "name": "commercial_subtype", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 + }, + "source_inspection_id": { + "name": "source_inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "root_inspection_id": { + "name": "root_inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reinspection_round": { + "name": "reinspection_round", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "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 + }, + "idx_inspections_root": { + "name": "idx_inspections_root", + "columns": [ + "root_inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspections_tenant_id_tenants_id_fk": { + "name": "inspections_tenant_id_tenants_id_fk", + "tableFrom": "inspections", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_inspector_id_users_id_fk": { + "name": "inspections_inspector_id_users_id_fk", + "tableFrom": "inspections", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_template_id_templates_id_fk": { + "name": "inspections_template_id_templates_id_fk", + "tableFrom": "inspections", + "tableTo": "templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_discount_code_id_discount_codes_id_fk": { + "name": "inspections_discount_code_id_discount_codes_id_fk", + "tableFrom": "inspections", + "tableTo": "discount_codes", + "columnsFrom": [ + "discount_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_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_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_enc": { + "name": "refresh_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_enabled": { + "name": "sync_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "default_item_id": { + "name": "default_item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "qbo_entity_map": { + "name": "qbo_entity_map", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_type": { + "name": "oi_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_id": { + "name": "oi_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qbo_type": { + "name": "qbo_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qbo_id": { + "name": "qbo_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qbo_sync_token": { + "name": "qbo_sync_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "synced_at": { + "name": "synced_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_qbo_entity_map_qbo": { + "name": "idx_qbo_entity_map_qbo", + "columns": [ + "tenant_id", + "qbo_type", + "qbo_id" + ], + "isUnique": true + }, + "idx_qbo_entity_map_oi": { + "name": "idx_qbo_entity_map_oi", + "columns": [ + "tenant_id", + "oi_type", + "oi_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "qbo_sync_errors": { + "name": "qbo_sync_errors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_type": { + "name": "oi_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_id": { + "name": "oi_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_msg": { + "name": "error_msg", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "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": {} + }, + "report_pdfs": { + "name": "report_pdfs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rendered_at": { + "name": "rendered_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_version": { + "name": "source_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ready'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_report_pdfs_inspection_type": { + "name": "uq_report_pdfs_inspection_type", + "columns": [ + "inspection_id", + "type", + "version_number" + ], + "isUnique": true + }, + "idx_report_pdfs_tenant": { + "name": "idx_report_pdfs_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_report_pdfs_status": { + "name": "idx_report_pdfs_status", + "columns": [ + "status" + ], + "isUnique": false + }, + "idx_report_pdfs_content_hash": { + "name": "idx_report_pdfs_content_hash", + "columns": [ + "inspection_id", + "type", + "content_hash" + ], + "isUnique": false + } + }, + "foreignKeys": { + "report_pdfs_tenant_id_tenants_id_fk": { + "name": "report_pdfs_tenant_id_tenants_id_fk", + "tableFrom": "report_pdfs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_versions": { + "name": "report_versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_json": { + "name": "snapshot_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prev_hash": { + "name": "prev_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_fingerprint": { + "name": "key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_amendment": { + "name": "is_amendment", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "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 + }, + "idx_report_versions_verify_token": { + "name": "idx_report_versions_verify_token", + "columns": [ + "verification_token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "service_inspectors": { + "name": "service_inspectors", + "columns": { + "service_id": { + "name": "service_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_service_inspectors_tenant": { + "name": "idx_service_inspectors_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "service_inspectors_service_id_user_id_pk": { + "columns": [ + "service_id", + "user_id" + ], + "name": "service_inspectors_service_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "services": { + "name": "services", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agreement_id": { + "name": "agreement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_services_tenant": { + "name": "idx_services_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "services_tenant_id_tenants_id_fk": { + "name": "services_tenant_id_tenants_id_fk", + "tableFrom": "services", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "services_template_id_templates_id_fk": { + "name": "services_template_id_templates_id_fk", + "tableFrom": "services", + "tableTo": "templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "services_agreement_id_agreements_id_fk": { + "name": "services_agreement_id_agreements_id_fk", + "tableFrom": "services", + "tableTo": "agreements", + "columnsFrom": [ + "agreement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "signing_keys": { + "name": "signing_keys", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key_enc": { + "name": "private_key_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key_iv": { + "name": "private_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Ed25519'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "signing_keys_tenant_id_tenants_id_fk": { + "name": "signing_keys_tenant_id_tenants_id_fk", + "tableFrom": "signing_keys", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sms_consent_log": { + "name": "sms_consent_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_type": { + "name": "recipient_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "disclosure_version": { + "name": "disclosure_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "captured_via": { + "name": "captured_via", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_sms_consent_contact": { + "name": "idx_sms_consent_contact", + "columns": [ + "tenant_id", + "contact_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sms_disclosure_versions": { + "name": "sms_disclosure_versions", + "columns": { + "version": { + "name": "version", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_seed": { + "name": "is_seed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_tags_tenant_name": { + "name": "idx_tags_tenant_name", + "columns": [ + "tenant_id", + "name" + ], + "isUnique": true + }, + "idx_tags_tenant": { + "name": "idx_tags_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "templates": { + "name": "templates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating_system_id": { + "name": "rating_system_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commercial_subtype": { + "name": "commercial_subtype", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "featured": { + "name": "featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "idx_templates_tenant": { + "name": "idx_templates_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_templates_rating_system": { + "name": "idx_templates_rating_system", + "columns": [ + "rating_system_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "templates_tenant_id_tenants_id_fk": { + "name": "templates_tenant_id_tenants_id_fk", + "tableFrom": "templates", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_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": {} + }, + "usage_counters": { + "name": "usage_counters", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period_key": { + "name": "period_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_usage_counters_tenant": { + "name": "idx_usage_counters_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_counters_tenant_id_metric_period_key_pk": { + "columns": [ + "tenant_id", + "metric", + "period_key" + ], + "name": "usage_counters_tenant_id_metric_period_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_outbox": { + "name": "sync_outbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_tried_at": { + "name": "last_tried_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_sync_outbox_status_created": { + "name": "idx_sync_outbox_status_created", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_configs": { + "name": "tenant_configs", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "site_name": { + "name": "site_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "support_email": { + "name": "support_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sender_email": { + "name": "sender_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reply_to": { + "name": "reply_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_mode": { + "name": "email_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'platform'" + }, + "sms_mode": { + "name": "sms_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'platform'" + }, + "sender_display_name": { + "name": "sender_display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_inspector_from_name": { + "name": "use_inspector_from_name", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "point_of_contact": { + "name": "point_of_contact", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'company'" + }, + "billing_url": { + "name": "billing_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_url": { + "name": "review_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_phone": { + "name": "company_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "integration_config": { + "name": "integration_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 + }, + "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 + }, + "reinspection_statuses": { + "name": "reinspection_statuses", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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": "'[]'" + }, + "permission_overrides": { + "name": "permission_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_invites_tenant": { + "name": "idx_invites_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_tenant_invites_pending_email": { + "name": "uq_tenant_invites_pending_email", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": true, + "where": "status = 'pending'" + } + }, + "foreignKeys": { + "tenant_invites_tenant_id_tenants_id_fk": { + "name": "tenant_invites_tenant_id_tenants_id_fk", + "tableFrom": "tenant_invites", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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 + }, + "signature_enabled": { + "name": "signature_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "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": "'manager'" + }, + "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 + }, + "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 + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terms_accepted": { + "name": "terms_accepted", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permission_overrides": { + "name": "permission_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "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 094d1e3ee..922b02d7d 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1781428433108, "tag": "0005_email_identity_and_signature", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1781445762726, + "tag": "0006_bent_gamma_corps", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 719719595..6d016b990 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,8 +25,12 @@ "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-easy-crop": "^6.0.2", + "react-konva": "^18.2.16", + "react-photo-album": "^3.6.0", "react-router": "^7.6.0", "stripe": "^22.2.0", + "yet-another-react-lightbox": "^3.32.0", "zod": "^4.4.3" }, "devDependencies": { @@ -3820,7 +3824,6 @@ "version": "15.7.15", "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, "license": "MIT" }, "node_modules/@types/qrcode": { @@ -3837,7 +3840,6 @@ "version": "18.3.29", "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.29.tgz", "integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==", - "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -3848,12 +3850,21 @@ "version": "18.3.7", "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" } }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmmirror.com/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -5365,7 +5376,6 @@ "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/data-view-buffer": { @@ -7677,6 +7687,18 @@ "dev": true, "license": "ISC" }, + "node_modules/its-fine": { + "version": "1.2.5", + "resolved": "https://registry.npmmirror.com/its-fine/-/its-fine-1.2.5.tgz", + "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.0" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz", @@ -7825,7 +7847,6 @@ "version": "10.2.5", "resolved": "https://registry.npmmirror.com/konva/-/konva-10.2.5.tgz", "integrity": "sha512-WwBoe/EBhFcv+seL1Wnp3OAOwOFjCY4nCCgpLRrzUzw1IX4lKf/lYhj2Z3qo9P9q2fA3h+OdGDlimSNqZJaY5A==", - "dev": true, "funding": [ { "type": "patreon", @@ -8634,6 +8655,12 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-wheel": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/normalize-wheel/-/normalize-wheel-1.0.1.tgz", + "integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==", + "license": "BSD-3-Clause" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", @@ -9488,12 +9515,93 @@ "react": "^18.3.1" } }, + "node_modules/react-easy-crop": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/react-easy-crop/-/react-easy-crop-6.0.2.tgz", + "integrity": "sha512-nY/YiNEuRjc851+/PsOR6Q7XoshmnXMl+oEOsxp3Ah0PrhECi5388jjRnHwsTFx3W0o2zPwvq85oljzUqZNpEw==", + "license": "MIT", + "dependencies": { + "normalize-wheel": "^1.0.1" + }, + "peerDependencies": { + "react": ">=16.4.0", + "react-dom": ">=16.4.0" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-konva": { + "version": "18.2.16", + "resolved": "https://registry.npmmirror.com/react-konva/-/react-konva-18.2.16.tgz", + "integrity": "sha512-bYs5TuTpaSSxBTZ79btaSXjDvLaQIZOVgBEg2ITMyc2p3jwbdIJDh7kOuK4ivxY8uZHWxmQP32bLxlwA17EgXQ==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.2", + "its-fine": "^1.1.1", + "react-reconciler": "~0.29.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "konva": "^8.0.1 || ^7.2.5 || ^9.0.0 || ^10.0.0", + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/react-photo-album": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/react-photo-album/-/react-photo-album-3.6.0.tgz", + "integrity": "sha512-W9NgI+0XxOYF/FLQJ/ZiKsizNQtGtgDdiFgojmTmpBKDGeGiWKfSzmbw3v9WAqzimPeiFJ2sEb9DO0nHRHP/OA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/igordanchenko" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "react": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-reconciler": { + "version": "0.29.2", + "resolved": "https://registry.npmmirror.com/react-reconciler/-/react-reconciler-0.29.2.tgz", + "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.14.2.tgz", @@ -11837,6 +11945,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yet-another-react-lightbox": { + "version": "3.32.0", + "resolved": "https://registry.npmmirror.com/yet-another-react-lightbox/-/yet-another-react-lightbox-3.32.0.tgz", + "integrity": "sha512-FWODOMrE07i3O5MeWRcYlcnAUk518zkUYKAe307pVW5pkey3hKMcAIWn8yMIURzGvbd3m9eghWO9CocEZmQPIg==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/igordanchenko" + }, + "peerDependencies": { + "@types/react": "^16 || ^17 || ^18 || ^19", + "@types/react-dom": "^16 || ^17 || ^18 || ^19", + "react": "^16.8.0 || ^17 || ^18 || ^19", + "react-dom": "^16.8.0 || ^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index c80f686ba..2c2fe2a71 100644 --- a/package.json +++ b/package.json @@ -80,8 +80,12 @@ "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-easy-crop": "^6.0.2", + "react-konva": "^18.2.16", + "react-photo-album": "^3.6.0", "react-router": "^7.6.0", "stripe": "^22.2.0", + "yet-another-react-lightbox": "^3.32.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/scripts/check-ds-tokens.mjs b/scripts/check-ds-tokens.mjs index 2b9ea8c3f..f8c04f6e3 100644 --- a/scripts/check-ds-tokens.mjs +++ b/scripts/check-ds-tokens.mjs @@ -38,6 +38,9 @@ const FILE_ALLOWLIST = [ // styled with white-alpha glass utilities throughout. Brand fills inside // it ARE tokenized; the neutral on-dark styling is intentional. join("app", "components", "editor", "PhotoStudio.tsx"), + // Image Studio react-konva annotator — same fixed-dark studio chrome as + // PhotoStudio.tsx (its replacement); neutral on-dark styling is intentional. + join("app", "components", "image-studio", "PhotoAnnotator.tsx"), ]; /** How many lines above a violation a `ds-allow` comment still excuses it. */ diff --git a/server/api/admin/branding.ts b/server/api/admin/branding.ts index d02310db6..396b233f0 100644 --- a/server/api/admin/branding.ts +++ b/server/api/admin/branding.ts @@ -141,6 +141,11 @@ export const adminBrandingRoutes = createApiRouter() const file = formData.get('logo') as File; if (!file || !(file instanceof File)) throw Errors.BadRequest('No logo file provided.'); + const MAX_LOGO_BYTES = 2_000_000; + const ALLOWED = ['image/png', 'image/svg+xml', 'image/jpeg', 'image/webp']; + if (file.size > MAX_LOGO_BYTES) throw Errors.BadRequest('logo > 2MB'); + if (!ALLOWED.includes(file.type)) throw Errors.BadRequest('logo must be png, svg, jpeg, or webp'); + const brandingService = c.var.services.branding; const logoUrl = await brandingService.uploadLogo(c.get('tenantId'), file); return c.json({ success: true, data: { logoUrl } }, 200); diff --git a/server/api/inspections.ts b/server/api/inspections.ts index 6cd582b8d..02b979481 100644 --- a/server/api/inspections.ts +++ b/server/api/inspections.ts @@ -47,6 +47,7 @@ import { ConflictListResponseSchema, ConflictResolveSchema, ConflictResolveResponseSchema, + CoverCropSchema, } from '../lib/validations/inspection.schema'; import { CreateTemplateSchema, UpdateTemplateSchema, TemplateSchemaV2Schema } from '../lib/validations/template.schema'; import { createApiResponseSchema, SuccessResponseSchema } from '../lib/validations/shared.schema'; @@ -1611,6 +1612,41 @@ const saveAnnotationRoute = createRoute(withMcpMetadata({ description: "Auto-generated placeholder for createInspectionItemsPhotosAnnotation (POST /{id}/items/{itemId}/photos/{photoIndex}/annotation, inspections domain). TODO: replace with a real description sourced from the handler." }, { scopes: ['write'], tier: 'extended' })); +// ── Image Studio (cover crop): POST /api/inspections/:id/cover ─────────────── +// Bakes a cropped JPEG derivative of the chosen cover source photo to R2 and +// records the re-editable crop transform. Mirrors the annotation save shape. +const setCoverCropRoute = createRoute(withMcpMetadata({ + method: 'post', + path: '/{id}/cover', + tags: ["inspections"], + summary: 'Set cropped report cover (baked JPEG derivative + crop transform)', + request: { + params: z.object({ + id: z.string().describe('Inspection id'), + }).describe('Cover crop path params'), + body: { + content: { + 'multipart/form-data': { + schema: z.object({ + image: z.unknown().openapi({ type: 'string', format: 'binary' }).describe('Baked cropped JPEG (2048px long edge)'), + sourceKey: z.string().describe('R2 key of the cover source photo this crop applies to'), + crop: z.string().describe('JSON-encoded CoverCrop transform (source-pixel coords)'), + }).describe('Cover crop multipart body'), + }, + }, + }, + }, + middleware: [requireRole('owner', 'manager', 'inspector')], + responses: { + 200: { + content: { 'application/json': { schema: createApiResponseSchema(z.object({ coverImageKey: z.string().describe('R2 key of the baked cropped cover derivative') })) } }, + description: 'Cropped cover saved', + }, + }, + operationId: "setInspectionCover", + description: "Bake and store a cropped report-cover JPEG derivative for an inspection and record its re-editable crop transform (POST /{id}/cover, inspections domain)." +}, { scopes: ['write'], tier: 'extended' })); + // ----------------------------------------------------------------------------- // Agent Accounts A3 — POST /api/inspections/:id/concierge/approve @@ -3133,6 +3169,22 @@ export const inspectionsRoutes = createApiRouter() ); return c.json({ success: true, data: result }, 200); }) + .openapi(setCoverCropRoute, async (c) => { + const { id } = c.req.valid('param'); + const tenantId = c.get('tenantId'); + const formData = await c.req.parseBody(); + const file = formData['image'] as File | undefined; + if (!file) throw Errors.BadRequest('image file required'); + let rawCrop: unknown; + try { rawCrop = JSON.parse(String(formData['crop'] ?? '{}')); } + catch { throw Errors.BadRequest('invalid crop'); } + const parsed = CoverCropSchema.safeParse(rawCrop); + if (!parsed.success) throw Errors.BadRequest('invalid crop'); + const sourceKey = String(formData['sourceKey'] ?? ''); + const bytes = await file.arrayBuffer(); + const result = await c.var.services.inspection.setCroppedCover(id, tenantId, sourceKey, bytes, parsed.data); + return c.json({ success: true, data: result }, 200); + }) .openapi(approveConciergeRoute, async (c) => { const { id } = c.req.valid('param'); const tenantId = c.get('tenantId'); diff --git a/server/lib/db/schema/inspection.ts b/server/lib/db/schema/inspection.ts index 26f947d99..9d41bc97c 100644 --- a/server/lib/db/schema/inspection.ts +++ b/server/lib/db/schema/inspection.ts @@ -106,6 +106,17 @@ export const inspections = sqliteTable('inspections', { // row used as the report cover image. NULL until the inspector picks // one; the Publish pre-flight surfaces this as a gate. coverPhotoId: text('cover_photo_id'), + // Image Studio (cover crop) — re-editable crop transform applied to the + // SOURCE image (cover_photo_id), in source-pixel coords. NULL = uncropped. + coverCrop: text('cover_crop', { mode: 'json' }).$type<{ + aspect: '3:2' | '16:9' | '1.91:1' | '4:3'; + orientation: 'landscape' | 'portrait'; + x: number; y: number; width: number; height: number; + }>(), + // Image Studio (cover crop) — R2 key of the baked cropped derivative + // (JPEG, 2048px long edge). Report/OG/PDF read THIS when set; falls back + // to cover_photo_id (uncropped source) otherwise. + coverImageKey: text('cover_image_key'), unit: text('unit'), propertyType: text('property_type'), commercialSubtype: text('commercial_subtype'), diff --git a/server/lib/validations/inspection.schema.ts b/server/lib/validations/inspection.schema.ts index 72f0bf9a5..138f3b314 100644 --- a/server/lib/validations/inspection.schema.ts +++ b/server/lib/validations/inspection.schema.ts @@ -606,3 +606,17 @@ export const ConflictResolveResponseSchema = z.object({ resolvedAt: z.string(), }), }).openapi('ConflictResolveResponse'); + +/** + * Image Studio (cover crop) — re-editable crop transform applied to the + * source cover image, in source-pixel coordinates. + */ +export const CoverCropSchema = z.object({ + aspect: z.enum(['3:2', '16:9', '1.91:1', '4:3']), + orientation: z.enum(['landscape', 'portrait']), + x: z.number().min(0), + y: z.number().min(0), + width: z.number().positive(), + height: z.number().positive(), +}); +export type CoverCrop = z.infer; diff --git a/server/services/inspection.service.ts b/server/services/inspection.service.ts index c088c45c6..5c3ba189c 100644 --- a/server/services/inspection.service.ts +++ b/server/services/inspection.service.ts @@ -6,7 +6,7 @@ import { Errors } from '../lib/errors'; import { computeReportStats, getRatingColor, getRatingBucket, mapCustomDefectsForReport, type RatingLevel } from '../lib/report-utils'; import { mapRatingSystemLevels } from '../lib/map-rating-levels'; import { z } from 'zod'; -import { InspectionSchema, InspectionListQuerySchema, CreateInspectionSchema } from '../lib/validations/inspection.schema'; +import { InspectionSchema, InspectionListQuerySchema, CreateInspectionSchema, type CoverCrop } from '../lib/validations/inspection.schema'; import { ScopedDB } from '../lib/db/scoped'; import { escapeLikePattern } from '../lib/db/like-escape'; @@ -29,6 +29,19 @@ import type { CannedDefect, TemplateSchemaV2 } from '../types/template-schema'; import { sha256Hex } from './signing-key.service'; import { RENDER_VERSION } from '../lib/pdf'; +/** + * Image Studio (cover crop) — resolves the cover image URL, preferring the + * baked cropped derivative (`coverImageKey`) over the uncropped source + * (`coverPhotoId`). Returns null when neither is set. + */ +export function resolveCoverUrl( + ins: { coverImageKey?: string | null; coverPhotoId?: string | null }, + makePhotoUrl: (key: string) => string, +): string | null { + const key = ins.coverImageKey ?? ins.coverPhotoId; + return key ? makePhotoUrl(key) : null; +} + /** Slug → label map for resolving aggregated recommendation badges in * getReportData. Built once at module load. */ const RECOMMENDATION_CATEGORY_LABELS = new Map( @@ -1844,6 +1857,32 @@ export class InspectionService { return { annotatedKey }; } + /** + * Image Studio (cover crop) — bakes a cropped JPEG derivative of the cover + * source image into R2 and records the re-editable crop transform. Mirrors + * saveAnnotation: the original source key (cover_photo_id) is preserved so + * the crop can be re-edited; the report reads cover_image_key first. + */ + async setCroppedCover( + inspectionId: string, + tenantId: string, + sourceKey: string, + bakedBytes: ArrayBuffer, + crop: CoverCrop, + ): Promise<{ coverImageKey: string }> { + if (!this.r2) throw Errors.BadRequest('Storage not available'); + await this.getInspection(inspectionId, tenantId); + const ok = await this.isInspectionPhotoKey(inspectionId, tenantId, sourceKey); + if (!ok) throw Errors.BadRequest('sourceKey does not reference a photo of this inspection'); + const coverImageKey = `${tenantId}/${inspectionId}/cover_${crypto.randomUUID()}.jpg`; + await this.r2.put(coverImageKey, bakedBytes, { httpMetadata: { contentType: 'image/jpeg' } }); + const db = this.getDrizzle(); + await db.update(inspections) + .set({ coverPhotoId: sourceKey, coverImageKey, coverCrop: crop }) + .where(and(eq(inspections.id, inspectionId), eq(inspections.tenantId, tenantId))); + return { coverImageKey }; + } + /** * Builds structured report data for a given inspection. * @@ -2269,7 +2308,7 @@ export class InspectionService { // DB-16 — resolved report cover image URL (cover_photo_id holds the // R2 key of an attached/pool photo). null when the inspector has not // picked a cover. The renderer consumes this directly. - coverPhotoUrl: inspection.coverPhotoId ? makePhotoUrl(inspection.coverPhotoId) : null, + coverPhotoUrl: resolveCoverUrl(inspection as { coverImageKey?: string | null; coverPhotoId?: string | null }, makePhotoUrl), stats: { total: stats.total, satisfactory: stats.satisfactory, monitor: stats.monitor, defect: stats.defect }, sections, ratingLevels: levels.length > 0 ? levels : [ diff --git a/tests/unit/cover-crop.spec.ts b/tests/unit/cover-crop.spec.ts new file mode 100644 index 000000000..5dd3d4d3a --- /dev/null +++ b/tests/unit/cover-crop.spec.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { resolveCoverUrl } from '../../server/services/inspection.service'; +describe('resolveCoverUrl', () => { + const make = (k: string) => `/api/photo/${k}`; + it('prefers the baked cover_image_key when present', () => { + expect(resolveCoverUrl({ coverImageKey: 'baked.jpg', coverPhotoId: 'src.jpg' }, make)).toBe('/api/photo/baked.jpg'); + }); + it('falls back to cover_photo_id', () => { + expect(resolveCoverUrl({ coverImageKey: null, coverPhotoId: 'src.jpg' }, make)).toBe('/api/photo/src.jpg'); + }); + it('null when neither set', () => { + expect(resolveCoverUrl({ coverImageKey: null, coverPhotoId: null }, make)).toBeNull(); + }); +}); + +import { CoverCropSchema } from '../../server/lib/validations/inspection.schema'; +describe('CoverCropSchema', () => { + const valid = { aspect: '3:2', orientation: 'landscape', x: 0, y: 0, width: 1200, height: 800 }; + it('accepts valid', () => { expect(CoverCropSchema.safeParse(valid).success).toBe(true); }); + it('rejects unknown aspect', () => { expect(CoverCropSchema.safeParse({ ...valid, aspect: '5:4' }).success).toBe(false); }); + it('rejects non-positive dims', () => { expect(CoverCropSchema.safeParse({ ...valid, width: 0 }).success).toBe(false); }); +}); + +import { beforeEach, vi } from 'vitest'; +import { eq } from 'drizzle-orm'; +import { InspectionService } from '../../server/services/inspection.service'; +import { createTestDb, setupSchema } from './db'; +import * as schema from '../../server/lib/db/schema'; +import { ScopedDB } from '../../server/lib/db/scoped'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; + +vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); +import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; + +const TENANT = '00000000-0000-0000-0000-0000000000aa'; +const INSPECTION_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; +const POOL_KEY = `${TENANT}/${INSPECTION_ID}/pool_photo.jpg`; +const CROP = { aspect: '3:2', orientation: 'landscape', x: 0, y: 0, width: 1200, height: 800 } as const; + +function makeFakeR2() { + const store = new Map(); + return { + bucket: { + put: vi.fn(async (key: string, value: ArrayBuffer) => { store.set(key, value); }), + get: vi.fn(async (key: string) => { + const v = store.get(key); + return v ? { arrayBuffer: async () => v } : null; + }), + } as unknown as R2Bucket, + store, + }; +} + +describe('InspectionService.setCroppedCover', () => { + let testDb: BetterSQLite3Database; + let svc: InspectionService; + let r2: ReturnType; + + beforeEach(async () => { + const fixture = createTestDb(); + testDb = fixture.db; + await setupSchema(fixture.sqlite); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockDrizzle as any).mockReturnValue(testDb); + r2 = makeFakeR2(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const sdb = new ScopedDB(testDb as any, TENANT); + svc = new InspectionService({} as D1Database, r2.bucket, sdb); + + await testDb.insert(schema.tenants).values({ + id: TENANT, name: 'Acme', slug: 'acme-cover', status: 'active', deploymentMode: 'shared', tier: 'free', createdAt: new Date(), + }); + await testDb.insert(schema.inspections).values({ + id: INSPECTION_ID, tenantId: TENANT, templateId: null, + propertyAddress: '1 Main St', clientName: 'C', clientEmail: 'c@example.com', + date: '2026-06-01', status: 'draft', paymentStatus: 'unpaid', price: 0, + paymentRequired: false, agreementRequired: false, createdAt: new Date(), + }); + await testDb.insert(schema.inspectionMediaPool).values({ + id: 'pool-1', inspectionId: INSPECTION_ID, tenantId: TENANT, + r2Key: POOL_KEY, url: `/api/photo/${POOL_KEY}`, uploadedAt: Date.now(), + }); + }); + + it('bakes the derivative to R2 and records crop transform on the row', async () => { + const bytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]).buffer; // JPEG SOI marker + const { coverImageKey } = await svc.setCroppedCover(INSPECTION_ID, TENANT, POOL_KEY, bytes, CROP); + + expect(coverImageKey).toMatch(new RegExp(`^${TENANT}/${INSPECTION_ID}/cover_.*\\.jpg$`)); + expect(r2.store.has(coverImageKey)).toBe(true); + expect(r2.store.get(coverImageKey)).toBe(bytes); + + const row = await testDb.select().from(schema.inspections) + .where(eq(schema.inspections.id, INSPECTION_ID)).get(); + expect(row!.coverImageKey).toBe(coverImageKey); + expect(row!.coverPhotoId).toBe(POOL_KEY); + expect(row!.coverCrop).toEqual(CROP); + }); + + it('rejects a sourceKey that does not belong to the inspection', async () => { + const bytes = new Uint8Array([0xff, 0xd8]).buffer; + await expect( + svc.setCroppedCover(INSPECTION_ID, TENANT, 'someone-elses/photo.jpg', bytes, CROP), + ).rejects.toThrow(); + }); +}); diff --git a/tests/web/unit/annotations.spec.ts b/tests/web/unit/annotations.spec.ts new file mode 100644 index 000000000..98fdd5368 --- /dev/null +++ b/tests/web/unit/annotations.spec.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { serializeAnnotations, deserializeAnnotations, type Annotation } from '~/components/image-studio/annotations'; +describe('annotations serialization', () => { + const anns: Annotation[] = [ + { kind: 'circle', x: 100, y: 120, r: 40 }, + { kind: 'arrow', x: 10, y: 10, x2: 90, y2: 90 }, + { kind: 'label', x: 50, y: 50, text: 'Crack' }, + { kind: 'freehand', x: 0, y: 0, points: [{ x: 0, y: 0 }, { x: 5, y: 6 }] }, + ]; + it('round-trips through JSON', () => { + expect(deserializeAnnotations(serializeAnnotations(anns))).toEqual(anns); + }); + it('deserializes empty/garbage to []', () => { + expect(deserializeAnnotations('')).toEqual([]); + expect(deserializeAnnotations('not json')).toEqual([]); + }); +}); diff --git a/tests/web/unit/inspection-media.spec.ts b/tests/web/unit/inspection-media.spec.ts new file mode 100644 index 000000000..842408e34 --- /dev/null +++ b/tests/web/unit/inspection-media.spec.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest'; +import { flattenMedia, type MediaApiBody } from '~/lib/inspection-media'; +describe('flattenMedia', () => { + const body: MediaApiBody = { data: { + attached: [{ key: 'a.jpg', url: '/p/a.jpg', itemLabel: 'Roof' }, { key: 'a.jpg', url: '/p/a.jpg', itemLabel: 'Roof' }], + pool: [{ key: 'b.jpg', url: '/p/b.jpg' }], + } }; + it('dedupes by key and labels attached vs pool', () => { + const out = flattenMedia(body); + expect(out).toHaveLength(2); + expect(out[0]).toEqual({ key: 'a.jpg', url: '/p/a.jpg', label: 'Roof' }); + expect(out[1]).toEqual({ key: 'b.jpg', url: '/p/b.jpg', label: 'Unattached' }); + }); + it('tolerates a missing data envelope', () => { expect(flattenMedia(null)).toEqual([]); }); +});